diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx index d599495e1cb9d..e9bc69d291f02 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx @@ -79,4 +79,5 @@ const props: State = { prevStep: () => null, nextStep: () => null, createdDb: {} as any, + handleOnTimeout: () => null, }; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx index ac8486abd2f29..e04b63962e0c2 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx @@ -56,6 +56,7 @@ export function CreateDatabaseView({ isDbCreateErr, prevStep, nextStep, + handleOnTimeout, }: State) { const [dbName, setDbName] = useState(''); const [dbUri, setDbUri] = useState(''); @@ -73,7 +74,10 @@ export function CreateDatabaseView({ } }, [isDbCreateErr]); - function handleOnProceed(validator: Validator, retry = false) { + function handleOnProceed( + validator: Validator, + { overwriteDb = false, retry = false } = {} + ) { if (!validator.validate()) { return; } @@ -84,12 +88,15 @@ export function CreateDatabaseView({ return; } - registerDatabase({ - labels, - name: dbName, - uri: `${dbUri}:${dbPort}`, - protocol: getDatabaseProtocol(dbEngine), - }); + registerDatabase( + { + labels, + name: dbName, + uri: `${dbUri}:${dbPort}`, + protocol: getDatabaseProtocol(dbEngine), + }, + { overwriteDb } + ); } return ( @@ -187,7 +194,11 @@ export function CreateDatabaseView({ handleOnProceed(validator, true /* retry */)} + retry={() => handleOnProceed(validator, { retry: true })} + onOverwrite={() => + handleOnProceed(validator, { overwriteDb: true }) + } + onTimeout={handleOnTimeout} close={clearAttempt} dbName={dbName} next={nextStep} diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx index 30d9437f56413..92698e3b1871c 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx @@ -17,11 +17,13 @@ */ import React from 'react'; +import { Info } from 'design/Alert'; import { CreateDatabaseDialog, CreateDatabaseDialogProps, } from './CreateDatabaseDialog'; +import { dbWithoutDbServerExistsErrorMsg, timeoutErrorMsg } from './const'; export default { title: 'Teleport/Discover/Database/CreateDatabase/Dialog', @@ -40,11 +42,34 @@ export const Success = () => ( ); +export const AllowSkipOnTimeout = () => ( + <> + Devs: it should be same state as success + + +); + +export const AllowOverwrite = () => ( + +); + const props: CreateDatabaseDialogProps = { pollTimeout: 8080000000, attempt: { status: 'processing' }, retry: () => null, close: () => null, next: () => null, + onOverwrite: () => null, + onTimeout: () => null, dbName: 'db-name', }; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx index 5e61fb1a3c418..6fd7ebc94f0a6 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx @@ -23,6 +23,7 @@ import { AnimatedProgressBar, ButtonPrimary, ButtonSecondary, + ButtonWarning, } from 'design'; import * as Icons from 'design/Icon'; import Dialog, { DialogContent } from 'design/DialogConfirmation'; @@ -30,6 +31,8 @@ import Dialog, { DialogContent } from 'design/DialogConfirmation'; import { Timeout } from 'teleport/Discover/Shared/Timeout'; import { TextIcon } from 'teleport/Discover/Shared'; +import { dbWithoutDbServerExistsErrorMsg, timeoutErrorMsg } from './const'; + import type { Attempt } from 'shared/hooks/useAttemptNext'; export type CreateDatabaseDialogProps = { @@ -38,6 +41,8 @@ export type CreateDatabaseDialogProps = { retry(): void; close(): void; next(): void; + onOverwrite(): void; + onTimeout(): void; dbName: string; }; @@ -48,27 +53,53 @@ export function CreateDatabaseDialog({ close, next, dbName, + onOverwrite, + onTimeout, }: CreateDatabaseDialogProps) { let content: JSX.Element; if (attempt.status === 'failed') { - content = ( - <> - - {' '} - - {attempt.statusText} - - - - Retry - - - Close - - - - ); - } else if (attempt.status === 'processing') { + /** + * Most likely cause of timeout is when we found a matching db_service + * but no db_server heartbeats. Most likely cause is because db_service + * has been stopped but is not removed from teleport yet (there is some + * minutes delay on expiry). + * + * We allow the user to proceed to the next step to re-deploy (replace) + * the db_service that has been stopped. + */ + if (attempt.statusText === timeoutErrorMsg) { + content = ; + } else { + // Only allow overwriting if the database error + // states that it's a existing database without a db_server. + const canOverwriteDb = attempt.statusText.includes( + dbWithoutDbServerExistsErrorMsg + ); + + // TODO(bl-nero): Migrate this to alert boxes. + content = ( + <> + + + {attempt.statusText} + + + + Retry + + {canOverwriteDb && ( + + Overwrite + + )} + + Close + + + + ); + } + } else if (attempt.status === 'processing' || attempt.status === '') { content = ( <> @@ -90,19 +121,8 @@ export function CreateDatabaseDialog({ ); - } else { - // success - content = ( - <> - - - Database "{dbName}" successfully registered - - - Next - - - ); + } else if (attempt.status === 'success') { + content = ; } return ( @@ -121,3 +141,15 @@ export function CreateDatabaseDialog({ ); } + +const SuccessContent = ({ dbName, onClick }) => ( + <> + + + Database "{dbName}" successfully registered + + + Next + + +); diff --git a/web/packages/teleport/src/Discover/Shared/AutoDiscovery/SelfHostedAutoDiscoveryDirections.story.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/const.ts similarity index 62% rename from web/packages/teleport/src/Discover/Shared/AutoDiscovery/SelfHostedAutoDiscoveryDirections.story.tsx rename to web/packages/teleport/src/Discover/Database/CreateDatabase/const.ts index 81b0e58b25e2c..377e3cf9a2b13 100644 --- a/web/packages/teleport/src/Discover/Shared/AutoDiscovery/SelfHostedAutoDiscoveryDirections.story.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/const.ts @@ -15,20 +15,9 @@ * 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 { SelfHostedAutoDiscoverDirections } from './SelfHostedAutoDiscoverDirections'; +export const timeoutErrorMsg = + 'Teleport could not detect your new database in time. Please try again.'; -export default { - title: 'Teleport/Discover/Shared/SelfHostedAutoDiscoveryDirections', -}; - -export const Directions = () => { - return ( - {}} - /> - ); -}; +export const dbWithoutDbServerExistsErrorMsg = + 'already exists but there are no Teleport agents proxying it'; 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..a9ac3e6499c11 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts @@ -28,9 +28,12 @@ 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'; +import { dbWithoutDbServerExistsErrorMsg, timeoutErrorMsg } from './const'; + import type { CreateDatabaseRequest, Database as DatabaseResource, @@ -100,8 +103,7 @@ export function useCreateDatabase() { setTimedOut(false); setAttempt({ status: 'failed', - statusText: - 'Teleport could not detect your new database in time. Please try again.', + statusText: timeoutErrorMsg, }); emitErrorEvent( `timeout polling for new database with an existing service` @@ -121,6 +123,7 @@ export function useCreateDatabase() { ...(agentMeta as DbMeta), resourceName: createdDb.name, awsRegion: createdDb.awsRegion, + awsVpcId: createdDb.awsVpcId, agentMatcherLabels: dbPollingResult.labels, db: dbPollingResult, serviceDeployedMethod: @@ -132,6 +135,22 @@ export function useCreateDatabase() { setAttempt({ status: 'success' }); }, [dbPollingResult]); + function handleOnTimeout() { + updateAgentMetaUponRequiringDeployment(createdDb); + } + + function updateAgentMetaUponRequiringDeployment(db: CreateDatabaseRequest) { + updateAgentMeta({ + ...(agentMeta as DbMeta), + resourceName: db.name, + awsRegion: db.awsRegion, + agentMatcherLabels: db.labels, + selectedAwsRdsDb: db.awsRds, + awsVpcId: db.awsVpcId, + }); + handleNextStep(); + } + // fetchDatabaseServer is the callback that is run every interval by the poller. // The poller will stop polling once a result returns (a dbServer). function fetchDatabaseServer(signal: AbortSignal) { @@ -162,23 +181,37 @@ export function useCreateDatabase() { }); } - function fetchDatabaseServers(query: string, limit: number) { + function fetchDatabaseServers(query: string) { const request = { query, - limit, }; return ctx.databaseService.fetchDatabases(clusterId, request); } - async function registerDatabase(db: CreateDatabaseRequest, newDb = false) { + async function registerDatabase( + db: CreateDatabaseRequest, + { newDb = false, overwriteDb = false } = {} + ) { // Set the timeout now, because this entire registering process // should take less than WAITING_TIMEOUT. setPollTimeout(Date.now() + WAITING_TIMEOUT); setAttempt({ status: 'processing' }); setIsDbCreateErr(false); + if (overwriteDb) { + try { + await ctx.databaseService.createDatabase(clusterId, { + ...db, + overwrite: true, + }); + setCreatedDb(db); + } catch (err) { + handleRequestError(err, 'failed to overwrite database: '); + return; + } + } // Attempt creating a new Database resource. - if (!createdDb || newDb) { + else if (!createdDb || newDb) { try { await ctx.databaseService.createDatabase(clusterId, db); setCreatedDb(db); @@ -195,9 +228,8 @@ export function useCreateDatabase() { return; } } - // Check and see if database resource need to be updated. - if (!newDb && requiresDbUpdate(db)) { + else if (requiresDbUpdate(db)) { try { await ctx.databaseService.updateDatabase(clusterId, { ...db, @@ -217,13 +249,7 @@ export function useCreateDatabase() { await ctx.databaseService.fetchDatabaseServices(clusterId); if (!findActiveDatabaseSvc(db.labels, services)) { - updateAgentMeta({ - ...(agentMeta as DbMeta), - resourceName: db.name, - awsRegion: db.awsRegion, - agentMatcherLabels: db.labels, - selectedAwsRdsDb: db.awsRds, - }); + updateAgentMetaUponRequiringDeployment(db); setAttempt({ status: 'success' }); return; } @@ -246,32 +272,29 @@ export function useCreateDatabase() { dbName: string, isAwsRds = false ) { - const preErrMsg = 'failed to register database: '; - const nonAwsMsg = `use a different name and try again`; - const awsMsg = `change (or define) the value of the \ - tag "TeleportDatabaseName" on the RDS instance and try again`; + const selfHostedMsg = `use a different name and retry.`; + const awsMsg = `alternatively upsert the value of the \ + AWS tag "TeleportDatabaseName" on the RDS instance and retry.`; try { await ctx.databaseService.fetchDatabase(clusterId, dbName); - let message = `a database with the name "${dbName}" is already \ - a part of this cluster, ${isAwsRds ? awsMsg : nonAwsMsg}`; - handleRequestError(new Error(message), preErrMsg); + let message = `A database with the name "${dbName}" is already \ + a part of this cluster, ${isAwsRds ? awsMsg : selfHostedMsg}`; + handleRequestError(new Error(message)); } catch (e) { - // No database server were found for the database name. + // No database server were found for the database name + // so it'll be safe to overwrite the database. if (e instanceof ApiError) { if (e.response.status === 404) { - let message = `a database with the name "${dbName}" already exists \ - but there are no database servers for it, you can remove this \ - database using the command, “tctl rm db/${dbName}”, or ${ - isAwsRds ? awsMsg : nonAwsMsg - }`; - handleRequestError(new Error(message), preErrMsg); + let message = `A database with the name "${dbName}" ${dbWithoutDbServerExistsErrorMsg}. \ + You can overwrite it, or ${isAwsRds ? awsMsg : selfHostedMsg}`; + handleRequestError(new Error(message)); } return; } // Display other errors as is. - handleRequestError(e, preErrMsg); + handleRequestError(e, 'failed to register database:'); } setIsDbCreateErr(true); } @@ -315,6 +338,11 @@ export function useCreateDatabase() { } function handleNextStep() { + if (isAws && !cfg.isCloud) { + handleNextStepForSelfHostedAwsEnrollment(); + return; + } + if (dbPollingResult) { if ( isAws && @@ -326,13 +354,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(); @@ -342,6 +385,7 @@ export function useCreateDatabase() { clearAttempt, registerDatabase, fetchDatabaseServers, + handleOnTimeout, canCreateDatabase: access.create, pollTimeout, dbEngine: resourceSpec.dbMeta.engine, 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 a18c3c9167540..25d0f4fb77f65 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 @@ -61,11 +61,19 @@ Init.parameters = { (req, res, ctx) => res(ctx.json({ securityGroups: securityGroupsResponse })) ), + rest.post( + cfg.getAwsDeployTeleportServiceUrl('test-integration'), + (req, res, ctx) => + res(ctx.json({ serviceDashboardUrl: 'some-dashboard-url' })) + ), + rest.post(cfg.api.awsSubnetListPath, (req, res, ctx) => + res(ctx.json({ subnets: subnetsResponse })) + ), ], }, }; -export const InitWithAutoEnroll = () => { +export const InitWithAutoDiscover = () => { return ( { ...getDbMeta(), autoDiscovery: { config: { name: '', discoveryGroup: '', aws: [] }, - requiredVpcsAndSubnets: {}, }, }} resourceSpec={getDbResourceSpec( @@ -85,7 +92,7 @@ export const InitWithAutoEnroll = () => { ); }; -InitWithAutoEnroll.parameters = { +InitWithAutoDiscover.parameters = { msw: { handlers: [ rest.post( @@ -96,11 +103,10 @@ InitWithAutoEnroll.parameters = { rest.post( cfg.getAwsRdsDbsDeployServicesUrl('test-integration'), (req, res, ctx) => - res( - ctx.json({ - clusterDashboardUrl: 'some-cluster-dashboard-url', - }) - ) + res(ctx.json({ clusterDashboardUrl: 'some-cluster-dashboard-url' })) + ), + rest.post(cfg.api.awsSubnetListPath, (req, res, ctx) => + res(ctx.json({ subnets: subnetsResponse })) ), ], }, @@ -135,6 +141,19 @@ InitWithLabels.parameters = { (req, res, ctx) => res(ctx.json({ securityGroups: securityGroupsResponse })) ), + rest.post( + cfg.getAwsDeployTeleportServiceUrl('test-integration'), + (req, res, ctx) => + res( + ctx.status(403), + ctx.json({ + message: 'Whoops, something went wrong.', + }) + ) + ), + rest.post(cfg.api.awsSubnetListPath, (req, res, ctx) => + res(ctx.json({ subnets: subnetsResponse })) + ), ], }, }; @@ -160,6 +179,14 @@ InitSecurityGroupsLoadingFailed.parameters = { }) ) ), + rest.post(cfg.api.awsSubnetListPath, (req, res, ctx) => + res( + ctx.status(403), + ctx.json({ + message: 'Whoops, error getting subnets', + }) + ) + ), ], }, }; @@ -179,10 +206,46 @@ InitSecurityGroupsLoading.parameters = { cfg.getListSecurityGroupsUrl('test-integration'), (req, res, ctx) => res(ctx.delay('infinite')) ), + rest.post(cfg.api.awsSubnetListPath, (req, res, ctx) => + res(ctx.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..6f6df15755cb2 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 @@ -85,23 +85,44 @@ describe('test AutoDeploy.tsx', () => { jest.useFakeTimers(); beforeEach(() => { - jest.restoreAllMocks(); + 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: [], + }, + ], + }); }); - test('init: labels are rendered, command is not rendered yet', () => { - const { teleCtx, discoverCtx } = getMockedContexts(); - - renderAutoDeploy(teleCtx, discoverCtx); - - expect(screen.getByText(/env: prod/i)).toBeInTheDocument(); - expect(screen.queryByText(/copy\/paste/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/curl/i)).not.toBeInTheDocument(); + afterEach(() => { + jest.restoreAllMocks(); }); - test('clicking button renders command', () => { + async function waitForSubnetsAndSecurityGroups() { + await screen.findByText('sg-id'); + await screen.findByText('subnet-id'); + } + + test('clicking button renders command', async () => { const { teleCtx, discoverCtx } = getMockedContexts(); renderAutoDeploy(teleCtx, discoverCtx); + await waitForSubnetsAndSecurityGroups(); fireEvent.click(screen.getByText(/generate command/i)); @@ -113,10 +134,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 +162,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 e60eadf209d91..82eec81b46747 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx @@ -18,7 +18,17 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { Box, ButtonSecondary, Link, Text, Mark } from 'design'; +import { + Box, + ButtonSecondary, + Link, + Text, + Mark, + H3, + Subtitle3, + Link as ExternalLink, + Flex, +} from 'design'; import * as Icons from 'design/Icon'; import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; @@ -32,7 +42,7 @@ import { SuccessBox, WaitingInfo, } from 'teleport/Discover/Shared/HintBox'; -import { integrationService } from 'teleport/services/integrations'; +import { integrationService, Regions } from 'teleport/services/integrations'; import { useDiscover, DbMeta } from 'teleport/Discover/useDiscover'; import { DiscoverEventStatus, @@ -48,14 +58,13 @@ import { TextIcon, useShowHint, Header, - DiscoverLabel, AlternateInstructionButton, } from '../../../Shared'; import { DeployServiceProp } from '../DeployService'; -import { hasMatchingLabels, Labels } from '../../common'; import { SelectSecurityGroups } from './SelectSecurityGroups'; +import { SelectSubnetIds } from './SelectSubnetIds'; import type { Database } from 'teleport/services/databases'; @@ -63,56 +72,73 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { const { emitErrorEvent, nextStep, emitEvent, agentMeta, updateAgentMeta } = useDiscover(); const { attempt, setAttempt } = useAttempt(''); - const [showLabelMatchErr, setShowLabelMatchErr] = useState(true); const [taskRoleArn, setTaskRoleArn] = useState('TeleportDatabaseAccess'); 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[] >([]); - const hasDbLabels = agentMeta?.agentMatcherLabels?.length; - const dbLabels = hasDbLabels ? agentMeta.agentMatcherLabels : []; - const [labels, setLabels] = useState([ - { name: '*', value: '*', isFixed: dbLabels.length === 0 }, - ]); const dbMeta = agentMeta as DbMeta; - useEffect(() => { - // Turn off error once user changes labels. - if (showLabelMatchErr) { - setShowLabelMatchErr(false); + function manuallyValidateRequiredFields() { + if (selectedSubnetIds.length === 0) { + setHasNoSubnets(true); + return false; + } else { + setHasNoSubnets(false); } - }, [labels]); + + if (selectedSecurityGroups.length === 0) { + setHasNoSecurityGroups(true); + return false; + } else { + setHasNoSecurityGroups(false); + } + + return true; // valid + } function handleDeploy(validator) { + setSvcDeployedAwsUrl(''); + setDeployFinished(false); + if (!validator.validate()) { return; } + if (!manuallyValidateRequiredFields()) { + return; + } + const integrationName = dbMeta.awsIntegration.name; + const { awsAccountId } = splitAwsIamArn( + agentMeta.awsIntegration.spec.roleArn + ); if (wantAutoDiscover) { setAttempt({ status: 'processing' }); - const requiredVpcsAndSubnets = - dbMeta.autoDiscovery.requiredVpcsAndSubnets; - const vpcIds = Object.keys(requiredVpcsAndSubnets); - - const { awsAccountId } = splitAwsIamArn( - agentMeta.awsIntegration.spec.roleArn - ); integrationService .deployDatabaseServices(integrationName, { region: dbMeta.awsRegion, accountId: awsAccountId, taskRoleArn, - deployments: vpcIds.map(vpcId => ({ - vpcId, - subnetIds: requiredVpcsAndSubnets[vpcId], - })), + deployments: [ + { + vpcId: dbMeta.awsVpcId, + subnetIds: selectedSubnetIds, + securityGroups: selectedSecurityGroups, + }, + ], }) .then(url => { setAttempt({ status: 'success' }); @@ -125,21 +151,16 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { emitErrorEvent(`auto discover deploy request failed: ${err.message}`); }); } else { - if (!hasMatchingLabels(dbLabels, labels)) { - setShowLabelMatchErr(true); - return; - } - - setShowLabelMatchErr(false); setAttempt({ status: 'processing' }); integrationService .deployAwsOidcService(integrationName, { deploymentMode: 'database-service', region: dbMeta.awsRegion, - subnetIds: dbMeta.selectedAwsRdsDb?.subnets, + subnetIds: selectedSubnetIds, taskRoleArn, - databaseAgentMatcherLabels: labels, securityGroups: selectedSecurityGroups, + vpcId: dbMeta.awsVpcId, + accountId: awsAccountId, }) // The user is still technically in the "processing" // state, because after this call succeeds, we will @@ -187,8 +208,8 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { } const wantAutoDiscover = !!dbMeta.autoDiscovery; - const isProcessing = attempt.status === 'processing' && !!svcDeployedAwsUrl; - const isDeploying = isProcessing && !!svcDeployedAwsUrl; + const isProcessing = attempt.status === 'processing' && !svcDeployedAwsUrl; + const isDeploying = attempt.status === 'processing' && !!svcDeployedAwsUrl; const hasError = attempt.status === 'failed'; return ( @@ -212,60 +233,55 @@ 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) - - - {/* step three */} - - Step 3 (Optional) - - - - )} + +
+

Step 2

+
+ +
+ + +
+

Step 3 (Optional)

+
+ +
- Step {wantAutoDiscover ? 2 : 4} - Deploy the Teleport Database Service. +
+

Step 4

+ + Deploy the Teleport Database Service + +
handleDeploy(validator)} - disabled={attempt.status === 'processing'} + disabled={isProcessing} mt={2} mb={2} > - Deploy Teleport Service + {isDeploying + ? 'Redeploy Teleport Service' + : 'Deploy Teleport Service'} {hasError && ( - + Encountered Error: {attempt.statusText} @@ -284,6 +300,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { resourceName={agentMeta.resourceName} abortDeploying={abortDeploying} svcDeployedAwsUrl={svcDeployedAwsUrl} + region={dbMeta.awsRegion} /> )} @@ -293,6 +310,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 + + )} + { // Starts resource querying interval. const { result, active } = usePingTeleport(resourceName); @@ -449,18 +481,57 @@ const DeployHints = ({ if (showHint && !result) { return ( - - The network may be slow. Try continuing to wait for a few more minutes - or{' '} - - try manually deploying your own service. - {' '} - You can visit your AWS{' '} - - dashboard - {' '} - to see progress details. - + + + Visit your AWS{' '} + + dashboard + {' '} + to see progress details. + + + There are a few possible reasons for why we haven't been able to + detect your database service: + +
    p.theme.space[3]}px; + `} + > +
  • + The subnets you selected do not route to an internet gateway (igw) + or a NAT gateway in a public subnet. +
  • +
  • + The security groups you selected do not allow outbound traffic + (eg: 0.0.0.0/0) to pull the public Teleport image and + to reach your Teleport cluster. +
  • +
  • + The security groups attached to your database(s) neither allow + inbound traffic from the security group you selected nor allow + inbound traffic from all IPs in the subnets you selected. +
  • +
  • + There may be issues in the region you selected ({region}). Check + the{' '} + + AWS Health Dashboard + {' '} + for any problems. +
  • +
  • + The network may be slow. Try waiting for a few more minutes or{' '} + + try manually deploying your own database service. + +
  • +
+
); } @@ -521,3 +592,7 @@ const StyledBox = styled(Box)` padding: ${props => `${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 bb18c1262ba7e..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,12 +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 { pluralize } from 'shared/utils/text'; +import { P, P3 } from 'design/Text/Text'; import { integrationService, @@ -44,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: [], @@ -74,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); @@ -103,13 +113,32 @@ export const SelectSecurityGroups = ({ return ( <> - Select Security Groups - + + 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' && ( <> @@ -136,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..d0029ebc014fd --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollment.tsx @@ -0,0 +1,254 @@ +/** + * 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, ButtonSecondary } from 'design'; +import { FetchStatus } from 'design/DataTable/types'; +import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; +import Alert 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 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 (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 && fetchAttempt.status !== 'failed'; + + return ( + <> + {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 64% 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 4841017a33a93..1974723bd48cc 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx @@ -59,8 +59,8 @@ export default { ], }; -export const InstanceList = () => ; -InstanceList.parameters = { +export const SelfHostedFlow = () => ; +SelfHostedFlow.parameters = { msw: { handlers: [ rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => @@ -73,26 +73,21 @@ InstanceList.parameters = { rest.post(cfg.api.discoveryConfigPath, (req, res, ctx) => res(ctx.json({})) ), - rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => - res( - ctx.json({ services: [{ name: 'test', matchers: { '*': ['*'] } }] }) - ) - ), rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => res(ctx.json({})) ), - rest.post(cfg.api.awsRdsDbRequiredVpcsPath, (req, res, ctx) => - res(ctx.json({ vpcMapOfSubnets: {} })) + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) ), ], }, }; -export const InstanceListForCloud = () => { +export const CloudFlow = () => { cfg.isCloud = true; return ; }; -InstanceListForCloud.parameters = { +CloudFlow.parameters = { msw: { handlers: [ rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => @@ -104,35 +99,108 @@ InstanceListForCloud.parameters = { rest.post(cfg.api.discoveryConfigPath, (req, res, ctx) => res(ctx.json({})) ), + rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) + ), + ], + }, +}; + +export const NoVpcs = () => { + return ; +}; +NoVpcs.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => + res(ctx.json({ databases: [] })) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res.once(ctx.json({ vpcs: [] })) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) + ), + ], + }, +}; + +export const VpcError = () => { + return ; +}; +VpcError.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res( + ctx.status(404), + ctx.json({ message: 'Whoops, error fetching required vpcs.' }) + ) + ), + ], + }, +}; + +export const SelectedVpcAlreadyExists = () => { + return ; +}; +SelectedVpcAlreadyExists.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => + res(ctx.json({ databases: rdsInstances })) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => res( ctx.json({ - items: [ - { name: 'test', resource_matchers: [{ labels: { '*': ['*'] } }] }, + vpcs: [ + { + id: 'Click me, then toggle ON auto enroll', + ecsServiceDashboardURL: 'http://some-dashboard-url', + }, + { + id: 'vpc-1234', + }, ], }) ) ), - rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => - res(ctx.json({})) + rest.get(cfg.api.databasesPath, (req, res, ctx) => + res(ctx.json({ items: [rdsInstances[2]] })) ), - rest.post(cfg.api.awsRdsDbRequiredVpcsPath, (req, res, ctx) => - res(ctx.json({ vpcMapOfSubnets: { 'vpc-1': ['subnet1'] } })) + ], + }, +}; + +export const LoadingVpcs = () => { + return ; +}; +LoadingVpcs.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.delay('infinite')) ), ], }, }; -export const InstanceListLoading = () => { - cfg.isCloud = true; +export const LoadingDatabases = () => { return ; }; -InstanceListLoading.parameters = { +LoadingDatabases.parameters = { msw: { handlers: [ rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => res(ctx.delay('infinite')) ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) + ), ], }, }; @@ -142,21 +210,59 @@ WithAwsPermissionsError.parameters = { msw: { handlers: [ rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => - res( + res(ctx.json({ databases: [] })) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res.once( ctx.status(403), ctx.json({ message: 'StatusCode: 403, RequestID: operation error' }) ) ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) + ), ], }, }; -export const WithOtherError = () => ; -WithOtherError.parameters = { +export const WithDbListError = () => ; +WithDbListError.parameters = { msw: { handlers: [ + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) + ), rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => - res(ctx.status(404)) + res( + ctx.status(403), + ctx.json({ message: 'Whoops, fetching aws databases error' }) + ) + ), + ], + }, +}; + +export const WithOneOfDbListError = () => ; +WithOneOfDbListError.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => + res.once(ctx.json({ databases: rdsInstances })) + ), + rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => + res.once( + ctx.status(403), + ctx.json({ message: 'Whoops, fetching another aws databases error' }) + ) + ), + rest.post(cfg.api.awsRdsDbListPath, (req, res, ctx) => + res(ctx.json({ databases: rdsInstances })) + ), + rest.get(cfg.api.databasesPath, (req, res, ctx) => + res(ctx.json({ items: [rdsInstances[2]] })) + ), + rest.post(cfg.api.awsDatabaseVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcs })) ), ], }, @@ -305,3 +411,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 a45ea63b1ca63..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, Text, 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 a 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..43dede55ed56d --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx @@ -0,0 +1,233 @@ +/** + * 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, + nextStep, + fetchDatabaseServers, + handleOnTimeout, + } = 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({ overwriteDb = false } = {}) { + // 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. + 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, + }, + { newDb: isNewDb, overwriteDb } + ); + } + + const showTable = !!vpc && fetchAttempt.status !== 'failed'; + + return ( + <> + {showTable && ( + <> + Select an RDS to enroll: + + + )} + + {attempt.status !== '' && ( + handleOnProceed({ overwriteDb: true })} + dbName={selectedDb.name} + /> + )} + + ); +} 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 ed226bf8e2066..61e493aa3b44f 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -526,13 +526,20 @@ type BaseMeta = { * in customers infrastructure such as Kubernetes clusters or databases hosted * on cloud platforms like AWS, Azure, etc. */ - autoDiscovery?: { - 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. - requiredVpcsAndSubnets?: Record; - }; + 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; + // 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. + requiredVpcsAndSubnets?: Record; }; // NodeMeta describes the fields for node resource diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 60e7f0dea4eb8..92fba138f8808 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -314,12 +314,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', @@ -900,6 +904,13 @@ const cfg = { }); }, + getAwsDatabaseVpcsUrl(integrationName: string, clusterId: string) { + return generatePath(cfg.api.awsDatabaseVpcsPath, { + clusterId, + name: integrationName, + }); + }, + getAwsRdsDbsDeployServicesUrl(integrationName: string) { const clusterId = cfg.proxyCluster; @@ -1004,6 +1015,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..6630646f9e4e4 100644 --- a/web/packages/teleport/src/services/databases/types.ts +++ b/web/packages/teleport/src/services/databases/types.ts @@ -70,6 +70,8 @@ export type CreateDatabaseRequest = { labels?: ResourceLabel[]; awsRds?: AwsRdsDatabase; awsRegion?: Regions; + awsVpcId?: string; + overwrite?: boolean; }; 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..f45b436394c43 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -50,6 +50,12 @@ import { EnrollEksClustersRequest, ListEksClustersRequest, AwsOidcDeployDatabaseServicesRequest, + Regions, + ListAwsRdsFromAllEnginesResponse, + ListAwsSubnetsRequest, + ListAwsSubnetsResponse, + Subnet, + AwsDatabaseVpcsResponse, } from './types'; export const integrationService = { @@ -95,12 +101,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 +383,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 +473,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 7ed7769195c6d..80dd390f5ec9a 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -297,6 +297,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; @@ -308,8 +325,9 @@ export type AwsOidcDeployServiceRequest = { region: Regions; subnetIds: string[]; taskRoleArn: string; - databaseAgentMatcherLabels: Label[]; securityGroups?: string[]; + vpcId: string; + accountId: string; }; // DeployDatabaseServiceDeployment identifies the required fields to deploy a DatabaseService. @@ -467,6 +485,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; @@ -521,3 +569,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; +};