diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx index 262de0dbbb78a..2c40f2d197da8 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx @@ -94,7 +94,7 @@ export function CreateDatabaseDialog({ // success content = ( <> - + Database "{dbName}" successfully registered @@ -106,7 +106,7 @@ export function CreateDatabaseDialog({ } return ( - + { return ( - + - + ); }; @@ -67,10 +65,57 @@ Init.parameters = { }, }; +export const InitWithAutoEnroll = () => { + return ( + + + + ); +}; +InitWithAutoEnroll.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getListSecurityGroupsUrl('test-integration'), + (req, res, ctx) => + res(ctx.json({ securityGroups: securityGroupsResponse })) + ), + rest.post( + cfg.getAwsRdsDbsDeployServicesUrl('test-integration'), + (req, res, ctx) => + res( + ctx.json({ + clusterDashboardUrl: 'some-cluster-dashboard-url', + }) + ) + ), + ], + }, +}; + export const InitWithLabels = () => { return ( - { }} > - + ); }; @@ -96,9 +141,9 @@ InitWithLabels.parameters = { export const InitSecurityGroupsLoadingFailed = () => { return ( - + - + ); }; @@ -121,9 +166,9 @@ InitSecurityGroupsLoadingFailed.parameters = { export const InitSecurityGroupsLoading = () => { return ( - + - + ); }; @@ -138,84 +183,6 @@ InitSecurityGroupsLoading.parameters = { }, }; -const Provider = props => { - const ctx = createTeleportContext(); - const discoverCtx: DiscoverContextState = { - agentMeta: { - resourceName: 'db-name', - awsRegion: 'us-east-1', - agentMatcherLabels: [], - db: { - aws: { - rds: { - region: 'us-east-1', - vpcId: 'test-vpc', - }, - }, - }, - selectedAwsRdsDb: { region: 'us-east-1' } as any, - awsIntegration: { - kind: 'aws-oidc', - name: 'test-integration', - resourceType: 'integration', - spec: { - roleArn: 'arn-123', - }, - statusCode: IntegrationStatusCode.Running, - }, - ...props.agentMeta, - } as DbMeta, - currentStep: 0, - nextStep: () => null, - prevStep: () => null, - onSelectResource: () => null, - resourceSpec: { - dbMeta: { - location: DatabaseLocation.Aws, - engine: DatabaseEngine.AuroraMysql, - }, - } as any, - exitFlow: () => null, - viewConfig: null, - indexedViews: [], - setResourceSpec: () => null, - updateAgentMeta: () => null, - emitErrorEvent: () => null, - emitEvent: () => null, - eventState: null, - }; - - return ( - - - - - - {props.children} - - - - - - ); -}; - -function createTeleportContext() { - const ctx = new TeleportContext(); - - ctx.isEnterprise = false; - ctx.storeUser.setState(getUserContext()); - - return ctx; -} - 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 67b7a190018d1..c239c9914c97c 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 @@ -197,12 +197,9 @@ function getMockedContexts() { eventState: null, }; - jest.spyOn(integrationService, 'deployAwsOidcService').mockResolvedValue({ - clusterArn: 'cluster-arn', - serviceArn: 'service-arn', - taskDefinitionArn: 'task-definition', - serviceDashboardUrl: 'dashboard-url', - }); + jest + .spyOn(integrationService, 'deployAwsOidcService') + .mockResolvedValue('dashboard-url'); jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ agents: [], 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 40d47518a84d7..c7776bde46f64 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx @@ -32,10 +32,7 @@ import { SuccessBox, WaitingInfo, } from 'teleport/Discover/Shared/HintBox'; -import { - AwsOidcDeployServiceResponse, - integrationService, -} from 'teleport/services/integrations'; +import { integrationService } from 'teleport/services/integrations'; import { useDiscover, DbMeta } from 'teleport/Discover/useDiscover'; import { DiscoverEventStatus, @@ -69,8 +66,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { const [showLabelMatchErr, setShowLabelMatchErr] = useState(true); const [taskRoleArn, setTaskRoleArn] = useState('TeleportDatabaseAccess'); - const [deploySvcResp, setDeploySvcResp] = - useState(); + const [svcDeployedAwsUrl, setSvcDeployedAwsUrl] = useState(''); const [deployFinished, setDeployFinished] = useState(false); const [selectedSecurityGroups, setSelectedSecurityGroups] = useState< @@ -96,37 +92,69 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { return; } - if (!hasMatchingLabels(dbLabels, labels)) { - setShowLabelMatchErr(true); - return; - } + const integrationName = dbMeta.awsIntegration.name; + + if (wantAutoDiscover) { + setAttempt({ status: 'processing' }); + + const requiredVpcsAndSubnets = + dbMeta.autoDiscovery.requiredVpcsAndSubnets; + const vpcIds = Object.keys(requiredVpcsAndSubnets); + + integrationService + .deployDatabaseServices(integrationName, { + region: dbMeta.awsRegion, + taskRoleArn, + deployments: vpcIds.map(vpcId => ({ + vpcId, + subnetIds: requiredVpcsAndSubnets[vpcId], + })), + }) + .then(url => { + setAttempt({ status: 'success' }); + setSvcDeployedAwsUrl(url); + setDeployFinished(true); + updateAgentMeta({ ...agentMeta, serviceDeployedMethod: 'auto' }); + }) + .catch((err: Error) => { + setAttempt({ status: 'failed', statusText: err.message }); + emitErrorEvent(`auto discover deploy request failed: ${err.message}`); + }); + } else { + if (!hasMatchingLabels(dbLabels, labels)) { + setShowLabelMatchErr(true); + return; + } - setShowLabelMatchErr(false); - setAttempt({ status: 'processing' }); - integrationService - .deployAwsOidcService(dbMeta.awsIntegration?.name, { - deploymentMode: 'database-service', - region: dbMeta.awsRegion, - subnetIds: dbMeta.selectedAwsRdsDb?.subnets, - taskRoleArn, - databaseAgentMatcherLabels: labels, - securityGroups: selectedSecurityGroups, - }) - // The user is still technically in the "processing" - // state, because after this call succeeds, we will - // start pinging for the newly registered db - // to get picked up by this service we deployed. - // So setting the attempt here to "success" - // is not necessary. - .then(setDeploySvcResp) - .catch((err: Error) => { - setAttempt({ status: 'failed', statusText: err.message }); - emitErrorEvent(`deploy request failed: ${err.message}`); - }); + setShowLabelMatchErr(false); + setAttempt({ status: 'processing' }); + integrationService + .deployAwsOidcService(integrationName, { + deploymentMode: 'database-service', + region: dbMeta.awsRegion, + subnetIds: dbMeta.selectedAwsRdsDb?.subnets, + taskRoleArn, + databaseAgentMatcherLabels: labels, + securityGroups: selectedSecurityGroups, + }) + // The user is still technically in the "processing" + // state, because after this call succeeds, we will + // start pinging for the newly registered db + // to get picked up by this service we deployed. + // So setting the attempt here to "success" + // is not necessary. + .then(setSvcDeployedAwsUrl) + .catch((err: Error) => { + setAttempt({ status: 'failed', statusText: err.message }); + emitErrorEvent(`deploy request failed: ${err.message}`); + }); + } } function handleOnProceed() { - nextStep(2); // skip the IAM policy view + // Auto discover skips the IAM policy view since + // we ask them to configure it as first step. + nextStep(2); emitEvent( { stepStatus: DiscoverEventStatus.Success }, { @@ -149,13 +177,14 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { `aborted in middle of auto deploying (>= 5 minutes of waiting)` ); } - setDeploySvcResp(null); + setSvcDeployedAwsUrl(null); setAttempt({ status: '' }); toggleDeployMethod(); } - const isProcessing = attempt.status === 'processing' && !!deploySvcResp; - const isDeploying = isProcessing && !!deploySvcResp; + const wantAutoDiscover = !!dbMeta.autoDiscovery; + const isProcessing = attempt.status === 'processing' && !!svcDeployedAwsUrl; + const isDeploying = isProcessing && !!svcDeployedAwsUrl; const hasError = attempt.status === 'failed'; return ( @@ -167,6 +196,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { toggleDeployMethod={abortDeploying} togglerDisabled={isProcessing} region={dbMeta.awsRegion} + wantAutoDiscover={wantAutoDiscover} /> {/* step one */} @@ -178,34 +208,40 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { validator={validator} /> - {/* step two */} - - - Step 2 (Optional) - - - - - {/* step three */} - - - + {/* 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 4 + Step {wantAutoDiscover ? 2 : 4} Deploy the Teleport Database Service. - {isDeploying && ( + {!wantAutoDiscover && isDeploying && ( + )} + + {wantAutoDiscover && svcDeployedAwsUrl && ( + )} @@ -262,10 +304,12 @@ const Heading = ({ toggleDeployMethod, togglerDisabled, region, + wantAutoDiscover, }: { toggleDeployMethod(): void; togglerDisabled: boolean; region: string; + wantAutoDiscover: boolean; }) => { return ( <> @@ -276,14 +320,18 @@ const Heading = ({ ECS Fargate container (2vCPU, 4GB memory) in your Amazon account with the ability to access databases in this region ({region}). You will only need to do this once per geographical region. -
-
- Want to deploy a database service manually from one of your existing - servers?{' '} - + {!wantAutoDiscover && ( + <> +
+
+ Want to deploy a database service manually from one of your existing + servers?{' '} + + + )} ); @@ -379,12 +427,12 @@ const DeployHints = ({ resourceName, deployFinished, abortDeploying, - deploySvcResp, + svcDeployedAwsUrl, }: { resourceName: string; deployFinished(dbResult: Database): void; abortDeploying(): void; - deploySvcResp: AwsOidcDeployServiceResponse; + svcDeployedAwsUrl: string; }) => { // Starts resource querying interval. const { result, active } = usePingTeleport(resourceName); @@ -407,7 +455,7 @@ const DeployHints = ({ try manually deploying your own service. {' '} You can visit your AWS{' '} - + dashboard {' '} to see progress details. @@ -440,7 +488,7 @@ const DeployHints = ({ least a minute for the Database Service to be created and joined to your cluster.
We will update this status once detected, meanwhile visit your AWS{' '} - + dashboard {' '} to see progress details. @@ -449,6 +497,23 @@ const DeployHints = ({ ); }; +export function AutoDiscoverDeploySuccess({ + svcDeployedAwsUrl, +}: { + svcDeployedAwsUrl: string; +}) { + return ( + + The required database services have been deployed successfully. Discovery + will complete in a minute. You can visit your AWS{' '} + + dashboard + {' '} + to see progress details. + + ); +} + const StyledBox = styled(Box)` max-width: 1000px; background-color: ${props => props.theme.colors.spotBackground[0]}; 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 2dafdf25682c8..bb18c1262ba7e 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx @@ -103,7 +103,6 @@ export const SelectSecurityGroups = ({ return ( <> - Step 3 (Optional) Select Security Groups Select security groups to assign to the Fargate service that will be diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollDialog.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollDialog.tsx new file mode 100644 index 0000000000000..63a4ffcb2215c --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoEnrollDialog.tsx @@ -0,0 +1,118 @@ +/** + * 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 { + Text, + Flex, + AnimatedProgressBar, + ButtonPrimary, + ButtonSecondary, +} from 'design'; +import * as Icons from 'design/Icon'; +import Dialog, { DialogContent } from 'design/DialogConfirmation'; + +import { Mark } from 'teleport/Discover/Shared'; + +import type { Attempt } from 'shared/hooks/useAttemptNext'; + +export type AutoEnrollDialog = { + attempt: Attempt; + retry(): void; + close(): void; + next(): void; + region: string; + skipDeployment: boolean; +}; + +export function AutoEnrollDialog({ + attempt, + retry, + close, + next, + region, + skipDeployment, +}: AutoEnrollDialog) { + let content: JSX.Element; + if (attempt.status === 'failed') { + content = ( + <> + + + {attempt.statusText} + + + + Retry + + + Close + + + + ); + } else if (attempt.status === 'processing') { + content = ( + <> + + + Next + + + ); + } else { + // success + content = ( + <> + + + + Discovery config successfully created. + {skipDeployment && ( + <> + {' '} + The discovery service can take a few minutes to finish + auto-enrolling RDS databases found in region{' '} + {region}. + + )} + + + + Next + + + ); + } + + return ( + + + + Creating Auto Discovery Config + + {content} + + + ); +} 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 8621b44948d84..47ab79a97eb9d 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx @@ -17,47 +17,55 @@ */ import React from 'react'; -import { MemoryRouter } from 'react-router'; -import { render, screen, fireEvent } from 'design/utils/testing'; +import { render, screen, fireEvent, act } from 'design/utils/testing'; -import { ContextProvider } from 'teleport'; import { AwsRdsDatabase, - IntegrationStatusCode, integrationService, } from 'teleport/services/integrations'; -import { createTeleportContext } from 'teleport/mocks/contexts'; -import cfg from 'teleport/config'; -import TeleportContext from 'teleport/teleportContext'; -import { - DiscoverContextState, - DiscoverProvider, -} from 'teleport/Discover/useDiscover'; -import { - DatabaseEngine, - DatabaseLocation, -} from 'teleport/Discover/SelectResource'; -import { FeaturesContextProvider } from 'teleport/FeaturesContext'; import { userEventService } from 'teleport/services/userEvent'; +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 { EnrollRdsDatabase } from './EnrollRdsDatabase'; +const defaultIsCloud = cfg.isCloud; + describe('test EnrollRdsDatabase.tsx', () => { beforeEach(() => { + cfg.isCloud = true; + jest + .spyOn(DatabaseService.prototype, 'fetchDatabases') + .mockResolvedValue({ agents: [] }); + jest + .spyOn(DatabaseService.prototype, 'createDatabase') + .mockResolvedValue({} as any); + jest + .spyOn(userEventService, 'captureDiscoverEvent') + .mockResolvedValue(undefined as never); + jest.spyOn(discoveryService, 'createDiscoveryConfig').mockResolvedValue({ + name: '', + discoveryGroup: '', + aws: [], + }); + jest + .spyOn(DatabaseService.prototype, 'fetchDatabaseServices') + .mockResolvedValue({ services: [] }); + }); + + afterEach(() => { + cfg.isCloud = defaultIsCloud; jest.restoreAllMocks(); }); test('without rds database result, does not attempt to fetch db servers', async () => { - const { ctx, discoverCtx } = getMockedContexts(); jest .spyOn(integrationService, 'fetchAwsRdsDatabases') .mockResolvedValue({ databases: [] }); - render( - - - - ); + render(); // select a region from selector. const selectEl = screen.getByLabelText(/aws region/i); @@ -69,20 +77,15 @@ describe('test EnrollRdsDatabase.tsx', () => { await screen.findByText(/no result/i); expect(integrationService.fetchAwsRdsDatabases).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + expect(DatabaseService.prototype.fetchDatabases).not.toHaveBeenCalled(); }); test('with rds database result, makes a fetch request for db servers', async () => { - const { ctx, discoverCtx } = getMockedContexts(); jest.spyOn(integrationService, 'fetchAwsRdsDatabases').mockResolvedValue({ databases: mockAwsDbs, }); - render( - - - - ); + render(); // select a region from selector. const selectEl = screen.getByLabelText(/aws region/i); @@ -94,75 +97,68 @@ describe('test EnrollRdsDatabase.tsx', () => { await screen.findByText(/rds-1/i); expect(integrationService.fetchAwsRdsDatabases).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); + expect(DatabaseService.prototype.fetchDatabases).toHaveBeenCalledTimes(1); }); -}); -function getMockedContexts() { - const ctx = createTeleportContext(); - const discoverCtx: DiscoverContextState = { - agentMeta: { - awsIntegration: { - kind: 'aws-oidc', - name: 'aws-oidc-integration', - resourceType: 'integration', - spec: { - roleArn: 'arn-123', - }, - statusCode: IntegrationStatusCode.Running, - }, - } as any, - currentStep: 0, - nextStep: jest.fn(x => x), - prevStep: () => null, - onSelectResource: () => null, - resourceSpec: { - dbMeta: { - location: DatabaseLocation.Aws, - engine: DatabaseEngine.AuroraMysql, - }, - } as any, - viewConfig: null, - exitFlow: null, - indexedViews: [], - setResourceSpec: () => null, - updateAgentMeta: jest.fn(x => x), - emitErrorEvent: () => null, - emitEvent: () => null, - eventState: null, - }; - jest - .spyOn(ctx.databaseService, 'fetchDatabases') - .mockResolvedValue({ agents: [] }); - jest - .spyOn(userEventService, 'captureDiscoverEvent') - .mockResolvedValue(undefined as never); - - return { ctx, discoverCtx }; -} - -function Wrapper( - props: React.PropsWithChildren<{ - ctx: TeleportContext; - discoverCtx: DiscoverContextState; - }> -) { - return ( - - - - - {props.children} - - - - - ); -} + test('auto enroll is on by default with no database services', async () => { + 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')); + + // Rds results renders result. + await screen.findByText(/rds-1/i); + + act(() => screen.getByText('Next').click()); + await screen.findByText(/Creating Auto Discovery Config/i); + expect(discoveryService.createDiscoveryConfig).toHaveBeenCalledTimes(1); + expect(integrationService.fetchAwsRdsRequiredVpcs).toHaveBeenCalledTimes(1); + + expect(DatabaseService.prototype.createDatabase).not.toHaveBeenCalled(); + }); + + 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(/rds-1/i); + + // disable auto enroll + expect(screen.getByText('Next')).toBeEnabled(); + act(() => screen.getByText(/auto-enroll all/i).click()); + expect(screen.getByText('Next')).toBeDisabled(); + + act(() => screen.getByRole('radio').click()); + + 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); + }); +}); const mockAwsDbs: AwsRdsDatabase[] = [ { @@ -178,3 +174,9 @@ const mockAwsDbs: AwsRdsDatabase[] = [ subnets: ['subnet1', 'subnet2'], }, ]; + +const Component = () => ( + + + +); diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index 94e213c2f8fb3..2fe8f794f5d87 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -17,14 +17,15 @@ */ import React, { useState } from 'react'; -import { Box, Text } from 'design'; +import { Box, Text, Toggle } from 'design'; import { FetchStatus } from 'design/DataTable/types'; import { Danger } from 'design/Alert'; -import useAttempt from 'shared/hooks/useAttemptNext'; +import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; +import { ToolTipInfo } from 'shared/components/ToolTip'; import { getErrMessage } from 'shared/utils/errorType'; -import { useDiscover } from 'teleport/Discover/useDiscover'; +import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; import { AwsRdsDatabase, RdsEngineIdentifier, @@ -36,13 +37,21 @@ 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, + DiscoveryConfig, + createDiscoveryConfig, +} from 'teleport/services/discovery'; +import useTeleport from 'teleport/useTeleport'; -import { ActionButtons, Header } from '../../Shared'; +import { ActionButtons, Header, Mark } from '../../Shared'; import { useCreateDatabase } from '../CreateDatabase/useCreateDatabase'; import { CreateDatabaseDialog } from '../CreateDatabase/CreateDatabaseDialog'; import { DatabaseList } from './RdsDatabaseList'; +import { AutoEnrollDialog } from './AutoEnrollDialog'; type TableData = { items: CheckedAwsRdsDatabase[]; @@ -76,9 +85,15 @@ export function EnrollRdsDatabase() { fetchDatabaseServers, } = useCreateDatabase(); - const { agentMeta, resourceSpec, emitErrorEvent } = useDiscover(); + 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 [tableData, setTableData] = useState({ items: [], @@ -86,6 +101,9 @@ export function EnrollRdsDatabase() { fetchStatus: 'disabled', }); const [selectedDb, setSelectedDb] = useState(); + const [wantAutoDiscover, setWantAutoDiscover] = useState(() => cfg.isCloud); + const [autoDiscoveryCfg, setAutoDiscoveryCfg] = useState(); + const [requiredVpcs, setRequiredVpcs] = useState>(); function fetchDatabasesWithNewRegion(region: Regions) { // Clear table when fetching with new region. @@ -174,6 +192,87 @@ export function EnrollRdsDatabase() { } } + function handleAndEmitRequestError( + err: Error, + cfg: { errorPrefix?: string; setAttempt?(attempt: Attempt): void } + ) { + const message = getErrMessage(err); + if (cfg.setAttempt) { + cfg.setAttempt({ + status: 'failed', + statusText: `${cfg.errorPrefix}${message}`, + }); + } + emitErrorEvent(`${cfg.errorPrefix}${message}`); + } + + async function enableAutoDiscovery() { + setAutoDiscoverAttempt({ status: 'processing' }); + + let requiredVpcsAndSubnets = requiredVpcs; + if (!requiredVpcsAndSubnets) { + try { + const { spec, name: integrationName } = agentMeta.awsIntegration; + const accountId = spec.roleArn + .split('arn:aws:iam::')[1] + .substring(0, 12); + requiredVpcsAndSubnets = + await integrationService.fetchAwsRdsRequiredVpcs(integrationName, { + region: tableData.currRegion, + accountId, + }); + + 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: DISCOVERY_GROUP_CLOUD, + 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 clear() { clearRegisterAttempt(); @@ -189,23 +288,54 @@ export function EnrollRdsDatabase() { } function handleOnProceed() { - 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. - selectedDb.name !== createdDb?.name + 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} + skipDeployment={requiredVpcs && Object.keys(requiredVpcs).length === 0} + /> ); } const hasIamPermError = isIamPermError(fetchDbAttempt); + const showTable = !hasIamPermError && tableData.currRegion; return ( @@ -222,14 +352,24 @@ export function EnrollRdsDatabase() { clear={clear} disableSelector={fetchDbAttempt.status === 'processing'} /> - {!hasIamPermError && tableData.currRegion && ( - + {showTable && ( + <> + {cfg.isCloud && ( + setWantAutoDiscover(b => !b)} + isDisabled={tableData.items.length === 0} + /> + )} + + )} {hasIamPermError && ( @@ -240,24 +380,22 @@ export function EnrollRdsDatabase() { /> )} + {showTable && wantAutoDiscover && ( + + Note: Auto-enroll will enroll all database engines + in this region (e.g. PostgreSQL, MySQL, Aurora). + + )} - {registerAttempt.status !== '' && ( - - )} + {DialogComponent} ); } @@ -274,3 +412,32 @@ function getRdsEngineIdentifier(engine: DatabaseEngine): RdsEngineIdentifier { return 'aurora-postgres'; } } + +function ToggleSection({ + wantAutoDiscover, + toggleWantAutoDiscover, + isDisabled, +}: { + wantAutoDiscover: boolean; + isDisabled: boolean; + toggleWantAutoDiscover(): void; +}) { + 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. + + + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx index 5c59d79bf50d6..47f17fd300205 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useEffect } from 'react'; import { MemoryRouter } from 'react-router'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { rest } from 'msw'; @@ -40,13 +40,25 @@ import { import { EnrollRdsDatabase } from './EnrollRdsDatabase'; +initialize(); +const defaultIsCloud = cfg.isCloud; + export default { title: 'Teleport/Discover/Database/EnrollRds', loaders: [mswLoader], + decorators: [ + Story => { + useEffect(() => { + // Clean up + return () => { + cfg.isCloud = defaultIsCloud; + }; + }, []); + return ; + }, + ], }; -initialize(); - export const InstanceList = () => ; InstanceList.parameters = { msw: { @@ -57,6 +69,56 @@ InstanceList.parameters = { rest.get(cfg.api.databasesPath, (req, res, ctx) => res(ctx.json({ items: [rdsInstances[2]] })) ), + rest.post(cfg.api.databasesPath, (req, res, ctx) => res(ctx.json({}))), + 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: {} })) + ), + ], + }, +}; + +export const InstanceListForCloud = () => { + cfg.isCloud = true; + return ; +}; +InstanceListForCloud.parameters = { + msw: { + handlers: [ + 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.discoveryConfigPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => + res( + ctx.json({ + items: [ + { name: 'test', resource_matchers: [{ labels: { '*': ['*'] } }] }, + ], + }) + ) + ), + rest.get(cfg.api.databaseServicesPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.post(cfg.api.awsRdsDbRequiredVpcsPath, (req, res, ctx) => + res(ctx.json({ vpcMapOfSubnets: { 'vpc-1': ['subnet1'] } })) + ), ], }, }; @@ -111,7 +173,7 @@ const Component = () => { name: 'test-oidc', resourceType: 'integration', spec: { - roleArn: 'arn-123', + roleArn: 'arn:aws:iam::123456789012:role/test-role-arn', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx index 833f86fd4c2f7..51f8c8038c202 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx @@ -37,16 +37,16 @@ type Props = { fetchNextPage(): void; onSelectDatabase(item: CheckedAwsRdsDatabase): void; selectedDatabase?: CheckedAwsRdsDatabase; + wantAutoDiscover: boolean; }; -const disabledText = `This RDS database is already enrolled and is a part of this cluster`; - export const DatabaseList = ({ items = [], fetchStatus = '', fetchNextPage, onSelectDatabase, selectedDatabase, + wantAutoDiscover, }: Props) => { return ( - disabledText={disabledText} item={item} key={`${item.name}${item.resourceId}`} isChecked={isChecked} onChange={onSelectDatabase} - disabled={item.dbServerExists} value={item.name} + {...disabledStates(item, wantAutoDiscover)} /> ); }, @@ -75,34 +74,34 @@ export const DatabaseList = ({ { key: 'name', headerText: 'Name', - render: ({ name, dbServerExists }) => ( - - {name} - + render: item => ( + {item.name} ), }, { key: 'engine', headerText: 'Engine', - render: ({ engine, dbServerExists }) => ( - - {engine} + render: item => ( + + {item.engine} ), }, { key: 'labels', headerText: 'Labels', - render: ({ labels, dbServerExists }) => ( - - + render: item => ( + + ), }, { key: 'status', headerText: 'Status', - render: item => , + render: item => ( + + ), }, ]} emptyText="No Results" @@ -114,11 +113,17 @@ export const DatabaseList = ({ ); }; -const StatusCell = ({ item }: { item: CheckedAwsRdsDatabase }) => { +const StatusCell = ({ + item, + wantAutoDiscover, +}: { + item: CheckedAwsRdsDatabase; + wantAutoDiscover: boolean; +}) => { const status = getStatus(item); return ( - + {item.status} @@ -164,3 +169,25 @@ const StatusLight = styled(Box)` return theme.colors.grey[300]; // Unknown }}; `; + +function disabledStates( + item: CheckedAwsRdsDatabase, + wantAutoDiscover: boolean +) { + const disabled = + item.status === 'failed' || + item.status === 'deleting' || + 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') { + disabledText = 'Not available, try refreshing the list'; + } else if (item.status === 'deleting') { + disabledText = 'Not available'; + } + + return { disabled, disabledText }; +} diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx index 5fab883b202c7..328ca17943723 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx @@ -17,120 +17,188 @@ */ import React from 'react'; -import { MemoryRouter } from 'react-router'; - -import { initSelectedOptionsHelper } from 'teleport/Discover/Shared/SetupAccess'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { rest } from 'msw'; + +import { noAccess, getAcl } from 'teleport/mocks/contexts'; +import cfg from 'teleport/config'; +import { ResourceKind } from 'teleport/Discover/Shared'; +import { TeleportProvider } from 'teleport/Discover/Fixtures/fixtures'; +import { + ComponentWrapper, + getDbMeta, + getDbResourceSpec, +} from 'teleport/Discover/Fixtures/databases'; import { DatabaseEngine, DatabaseLocation } from '../../SelectResource'; -import { SetupAccess } from './SetupAccess'; - -import type { State } from 'teleport/Discover/Shared/SetupAccess'; +import SetupAccess from './SetupAccess'; export default { title: 'Teleport/Discover/Database/SetupAccess', + loaders: [mswLoader], + parameters: { + msw: { + handlers: { + fetchUser: rest.get(cfg.api.userWithUsernamePath, (req, res, ctx) => + res( + ctx.json({ + name: 'llama', + roles: ['access'], + traits: dynamicTraits, + }) + ) + ), + }, + }, + }, }; -export const NoTraits = () => ( - - []} /> - -); +initialize(); + +export const NoTraits = () => { + const meta = getDbMeta(); + meta.db.users = []; + meta.db.names = []; + return ( + + + + ); +}; +NoTraits.parameters = { + msw: { + handlers: { + fetchUser: [ + rest.get(cfg.api.userWithUsernamePath, (req, res, ctx) => + res(ctx.json({})) + ), + ], + }, + }, +}; export const WithTraitsAwsPostgres = () => ( - - - + + + ); +export const WithTraitsAwsPostgresAutoEnroll = () => { + const meta = getDbMeta(); + meta.db = undefined; + return ( + + + + ); +}; + export const WithTraitsAwsMySql = () => ( - - - + + + ); export const WithTraitsPostgres = () => ( - - - + + + ); export const WithTraitsMongo = () => ( - - - + + + ); export const WithTraitsMySql = () => ( - - - + + + ); export const NoAccess = () => ( - - - + + + ); export const SsoUser = () => ( - - - + + + ); -const props: State = { - attempt: { - status: 'success', - statusText: '', - }, - agentMeta: {} as any, - onProceed: () => null, - onPrev: () => null, - fetchUserTraits: () => null, - isSsoUser: false, - canEditUser: true, - getFixedOptions: () => [], - getSelectableOptions: () => [], - initSelectedOptions: trait => - initSelectedOptionsHelper({ trait, staticTraits, dynamicTraits }), - dynamicTraits: {} as any, - staticTraits: {} as any, - resourceSpec: getDbMeta(DatabaseEngine.Postgres, DatabaseLocation.SelfHosted), -}; - -const staticTraits = { - databaseUsers: ['staticUser1', 'staticUser2'], - databaseNames: ['staticName1', 'staticName2'], - logins: [], - kubeUsers: [], - kubeGroups: [], - windowsLogins: [], - awsRoleArns: [], -}; - const dynamicTraits = { - databaseUsers: ['dynamicUser1', 'dynamicUser2'], databaseNames: ['dynamicName1', 'dynamicName2'], + databaseUsers: ['dynamicUser1', 'dynamicUser2'], logins: [], kubeUsers: [], kubeGroups: [], windowsLogins: [], awsRoleArns: [], }; - -function getDbMeta(dbEngine: DatabaseEngine, dbLocation?: DatabaseLocation) { - return { - // Only these fields are relevant. - dbMeta: { - dbEngine, - dbLocation, - }, - } as any; -} diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx index bbf0aab5dc59e..6a7080cd05f26 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx @@ -31,14 +31,14 @@ import { import { Mark, StyledBox } from 'teleport/Discover/Shared'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; import { DbMeta } from 'teleport/Discover/useDiscover'; +import { Tabs } from 'teleport/components/Tabs'; import { DatabaseEngine, DatabaseLocation } from '../../SelectResource'; -import type { AgentStepProps } from '../../types'; import type { State } from 'teleport/Discover/Shared/SetupAccess'; -export default function Container(props: AgentStepProps) { - const state = useUserTraits(props); +export default function Container() { + const state = useUserTraits(); return ; } @@ -59,6 +59,8 @@ export function SetupAccess(props: State) { const [userInputValue, setUserInputValue] = useState(''); const [selectedUsers, setSelectedUsers] = useState([]); + const wantAutoDiscover = !!agentMeta.autoDiscovery; + useEffect(() => { if (props.attempt.status === 'success') { setSelectedNames(initSelectedOptions('databaseNames')); @@ -95,7 +97,17 @@ export function SetupAccess(props: State) { } function handleOnProceed() { - onProceed({ databaseNames: selectedNames, databaseUsers: selectedUsers }); + let numStepsToIncrement; + // Skip test connection since test connection currently + // only supports one resource testing and auto enrolling + // enrolls resources > 1. + if (wantAutoDiscover) { + numStepsToIncrement = 2; + } + onProceed( + { databaseNames: selectedNames, databaseUsers: selectedUsers }, + numStepsToIncrement + ); } const { engine, location } = resourceSpec.dbMeta; @@ -111,6 +123,15 @@ export function SetupAccess(props: State) { const dbMeta = agentMeta as DbMeta; + let infoContent = ( + + + + ); + if (wantAutoDiscover) { + infoContent = ; + } + return ( } + infoContent={infoContent} // Don't allow going back to previous screen when deploy db // service got skipped or user auto deployed the db service. onPrev={dbMeta.serviceDeployedMethod === 'manual' ? onPrev : null} + wantAutoDiscover={wantAutoDiscover} > + {wantAutoDiscover && ( + + Since auto-discovery is enabled, make sure to include all database + users and names that will be used to connect to the discovered + databases. + + )} Database Users ( - + <> To allow access using your Database Users @@ -197,7 +226,7 @@ const Info = (props: { - + ); function DbEngineInstructions({ @@ -382,3 +411,30 @@ function DbEngineInstructions({ return null; } + +// If auto discovery was enabled, users need to see all supported engine info +// to help setup access to their databases. +const AutoDiscoverInfoTabs = ({ location }: { location: DatabaseLocation }) => { + return ( + + + + ), + }, + { + title: `MySQL`, + content: ( + + + + ), + }, + ]} + /> + ); +}; diff --git a/web/packages/teleport/src/Discover/Database/index.tsx b/web/packages/teleport/src/Discover/Database/index.tsx index 45d242530ed17..06d1b2676b385 100644 --- a/web/packages/teleport/src/Discover/Database/index.tsx +++ b/web/packages/teleport/src/Discover/Database/index.tsx @@ -68,12 +68,23 @@ export const DatabaseResource: ResourceViewConfig = { component: EnrollRdsDatabase, eventName: DiscoverEvent.DatabaseRDSEnrollEvent, }, + // There are two types of deploy service methods: + // - manual: user deploys it whereever they want OR + // - auto (default): we deploy for them using aws + // fargate container { title: 'Deploy Database Service', component: DeployService, eventName: DiscoverEvent.DeployService, manuallyEmitSuccessEvent: true, }, + // This step can be skipped for the following. + // In the enroll RDS step: + // - if user opted to auto-enroll all databases + // - or if a db service was already found and in the db server + // polling result there is a iamPolicyStatus === Success + // Or if user auto deployed a database service (the first step + // requires them to configure IAM policy) { title: 'Configure IAM Policy', component: IamPolicy, diff --git a/web/packages/teleport/src/Discover/Fixtures/databases.tsx b/web/packages/teleport/src/Discover/Fixtures/databases.tsx new file mode 100644 index 0000000000000..5902959d9ee04 --- /dev/null +++ b/web/packages/teleport/src/Discover/Fixtures/databases.tsx @@ -0,0 +1,108 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { PropsWithChildren } from 'react'; + +import { + DatabaseEngine, + DatabaseLocation, + ResourceSpec, +} from 'teleport/Discover/SelectResource'; +import { + IntegrationKind, + IntegrationStatusCode, +} from 'teleport/services/integrations'; +import { DbMeta } from 'teleport/Discover/useDiscover'; +import { IamPolicyStatus } from 'teleport/services/databases'; + +import { DATABASES } from '../SelectResource/databases'; +import { ResourceKind } from '../Shared'; + +import { TeleportProvider } from './fixtures'; + +export function getDbResourceSpec( + engine: DatabaseEngine, + location?: DatabaseLocation +): ResourceSpec { + return { + ...DATABASES[0], + dbMeta: { + engine, + location, + }, + }; +} + +export function getDbMeta(): DbMeta { + return { + resourceName: 'db-name', + awsRegion: 'us-east-1', + agentMatcherLabels: [], + db: { + aws: { + iamPolicyStatus: IamPolicyStatus.Unspecified, + rds: { + region: 'us-east-1', + vpcId: 'test-vpc', + resourceId: 'some-rds-resource-id', + subnets: [], + }, + }, + kind: 'db', + name: 'some-db-name', + description: 'some-description', + type: 'rds', + protocol: 'postgres', + labels: [], + hostname: 'some-db-hostname', + users: ['staticUser1', 'staticUser2'], + names: ['staticName1', 'staticName2'], + }, + selectedAwsRdsDb: { + region: 'us-east-1', + engine: 'postgres', + name: 'rds-1', + uri: '', + resourceId: 'some-rds-resource-id', + accountId: '1234', + labels: [], + subnets: [], + vpcId: '', + status: 'available', + }, + awsIntegration: { + kind: IntegrationKind.AwsOidc, + name: 'test-integration', + resourceType: 'integration', + spec: { + roleArn: 'arn:aws:iam::123456789012:role/test-role-arn', + }, + statusCode: IntegrationStatusCode.Running, + }, + }; +} + +export const ComponentWrapper: React.FC = ({ children }) => ( + + {children} + +); diff --git a/web/packages/teleport/src/Discover/Fixtures/fixtures.tsx b/web/packages/teleport/src/Discover/Fixtures/fixtures.tsx new file mode 100644 index 0000000000000..03f4a5ae9df88 --- /dev/null +++ b/web/packages/teleport/src/Discover/Fixtures/fixtures.tsx @@ -0,0 +1,106 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; + +import { ResourceSpec } from 'teleport/Discover/SelectResource'; +import { ContextProvider } from 'teleport'; +import { + DiscoverProvider, + DiscoverContextState, + AgentMeta, +} from 'teleport/Discover/useDiscover'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { PingTeleportProvider } from 'teleport/Discover/Shared/PingTeleportContext'; +import { FeaturesContextProvider } from 'teleport/FeaturesContext'; +import { ResourceKind } from 'teleport/Discover/Shared'; +import cfg from 'teleport/config'; +import { Acl, AuthType } from 'teleport/services/user'; + +export const TeleportProvider: React.FC< + PropsWithChildren<{ + agentMeta: AgentMeta; + resourceSpec?: ResourceSpec; + interval?: number; + customAcl?: Acl; + authType?: AuthType; + resourceKind: ResourceKind; + }> +> = props => { + const ctx = createTeleportContext({ customAcl: props.customAcl }); + if (props.authType) { + ctx.storeUser.state.authType = props.authType; + } + const discoverCtx = defaultDiscoverContext({ + agentMeta: props.agentMeta, + resourceSpec: props.resourceSpec, + }); + + return ( + + + + + + {props.children} + + + + + + ); +}; + +export function defaultDiscoverContext({ + agentMeta, + resourceSpec, +}: { + agentMeta?: AgentMeta; + resourceSpec?: ResourceSpec; +}): DiscoverContextState { + return { + agentMeta: agentMeta + ? agentMeta + : { resourceName: '', agentMatcherLabels: [] }, + exitFlow: () => null, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: () => null, + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, + currentStep: 0, + nextStep: () => null, + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: resourceSpec ? resourceSpec : defaultResourceSpec(null), + }; +} + +export function defaultResourceSpec(kind: ResourceKind): ResourceSpec { + return { + name: '', + kind, + icon: null, + keywords: '', + event: null, + }; +} diff --git a/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.tsx index 01557fcb91bee..aff3a76e279f4 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.tsx @@ -28,11 +28,10 @@ import { SetupAccessWrapper, } from 'teleport/Discover/Shared/SetupAccess'; -import type { AgentStepProps } from '../../types'; import type { State } from 'teleport/Discover/Shared/SetupAccess'; -export default function Container(props: AgentStepProps) { - const state = useUserTraits(props); +export default function Container() { + const state = useUserTraits(); return ; } diff --git a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx index ef1c96c31370b..ba0e2228bae8d 100644 --- a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx @@ -28,11 +28,10 @@ import { SetupAccessWrapper, } from 'teleport/Discover/Shared/SetupAccess'; -import type { AgentStepProps } from '../../types'; import type { State } from 'teleport/Discover/Shared/SetupAccess'; -export default function Container(props: AgentStepProps) { - const state = useUserTraits(props); +export default function Container() { + const state = useUserTraits(); return ; } diff --git a/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx b/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx index 48d4fb0527eda..fbad0b412c742 100644 --- a/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx +++ b/web/packages/teleport/src/Discover/Shared/AwsAccount/AwsAccount.tsx @@ -121,6 +121,11 @@ export function AwsAccount() { } }); setAwsIntegrations(options); + + // Auto select the only option. + if (options.length === 1) { + setSelectedAwsIntegration(options[0]); + } }) ); } diff --git a/web/packages/teleport/src/Discover/Shared/Finished/Finished.story.tsx b/web/packages/teleport/src/Discover/Shared/Finished/Finished.story.tsx index 2e3ab49c96fbe..cd94bbf9c05e7 100644 --- a/web/packages/teleport/src/Discover/Shared/Finished/Finished.story.tsx +++ b/web/packages/teleport/src/Discover/Shared/Finished/Finished.story.tsx @@ -28,8 +28,30 @@ export default { export const Finished = () => ; +export const FinishedWithAutoEnroll = () => ( + +); + const props: AgentStepProps = { - agentMeta: { resourceName: 'some-resource-name' } as any, + agentMeta: { resourceName: 'some-resource-name', agentMatcherLabels: [] }, updateAgentMeta: () => null, nextStep: () => null, }; diff --git a/web/packages/teleport/src/Discover/Shared/Finished/Finished.tsx b/web/packages/teleport/src/Discover/Shared/Finished/Finished.tsx index 1f8890ce3eb71..afa92f4b583ac 100644 --- a/web/packages/teleport/src/Discover/Shared/Finished/Finished.tsx +++ b/web/packages/teleport/src/Discover/Shared/Finished/Finished.tsx @@ -17,6 +17,7 @@ */ import React from 'react'; +import styled from 'styled-components'; import { ButtonPrimary, Text, Flex, ButtonSecondary, Image } from 'design'; import cfg from 'teleport/config'; @@ -27,6 +28,35 @@ import celebratePamPng from './celebrate-pam.png'; import type { AgentStepProps } from '../../types'; export function Finished(props: AgentStepProps) { + if (props.agentMeta.autoDiscovery) { + return ( + + + + Completed Setup + + You have completed setup for auto-enrolling. + + history.push(cfg.routes.root, true)} + mr={3} + > + Browse Existing Resources + + history.reload()} + > + Add Another Resource + + + + ); + } + let resourceText; if (props.agentMeta && props.agentMeta.resourceName) { resourceText = `Resource [${props.agentMeta.resourceName}] has been successfully added to @@ -34,15 +64,7 @@ export function Finished(props: AgentStepProps) { } return ( - + Resource Successfully Added @@ -68,6 +90,14 @@ export function Finished(props: AgentStepProps) { Add Another Resource - + ); } + +const Container = styled(Flex)` + width: 600px; + flex-direction: column; + align-items: center; + margin: 0 auto; + text-align: center; +`; diff --git a/web/packages/teleport/src/Discover/Shared/HintBox.tsx b/web/packages/teleport/src/Discover/Shared/HintBox.tsx index ba45bc55b2b5d..32af3085023ac 100644 --- a/web/packages/teleport/src/Discover/Shared/HintBox.tsx +++ b/web/packages/teleport/src/Discover/Shared/HintBox.tsx @@ -91,7 +91,7 @@ export function SuccessBox(props: { children: React.ReactNode }) { > - {props.children} + {props.children} ); } diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx b/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx index 5815dcc13f941..d1a8dae721501 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx @@ -46,6 +46,7 @@ export type Props = { onPrev(): void; children: React.ReactNode; infoContent?: React.ReactNode; + wantAutoDiscover?: boolean; }; export function SetupAccessWrapper({ @@ -61,6 +62,7 @@ export function SetupAccessWrapper({ onPrev, children, infoContent, + wantAutoDiscover = false, }: Props) { const canAddTraits = !isSsoUser && canEditUser; @@ -139,6 +141,7 @@ export function SetupAccessWrapper({ { - const ctx = createTeleportContext(); - jest.spyOn(ctx.userService, 'fetchUser').mockResolvedValue(getMockUser()); - jest.spyOn(ctx.userService, 'updateUser').mockResolvedValue(null); - jest.spyOn(ctx.userService, 'reloadUser').mockResolvedValue(null); + const teleCtx = createTeleportContext(); + jest.spyOn(teleCtx.userService, 'fetchUser').mockResolvedValue(getMockUser()); + jest.spyOn(teleCtx.userService, 'updateUser').mockResolvedValue(null); + jest.spyOn(teleCtx.userService, 'reloadUser').mockResolvedValue(null); jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(null as never); // return value does not matter but required by ts - let wrapper; - - beforeEach(() => { - wrapper = ({ children }) => ( - - - - {children} - - - - ); - }); - afterEach(() => { jest.clearAllMocks(); }); test('kubernetes', async () => { - const props = { - agentMeta: getMeta(ResourceKind.Kubernetes) as AgentMeta, - updateAgentMeta: jest.fn(x => x), - nextStep: () => null, - resourceSpec: { kind: ResourceKind.Kubernetes } as any, + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(ResourceKind.Kubernetes), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(ResourceKind.Kubernetes), }; + const spyUpdateAgentMeta = jest + .spyOn(discoverCtx, 'updateAgentMeta') + .mockImplementation(x => x); - const { result } = renderHook(() => useUserTraits(props), { - wrapper, + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), }); await waitFor(() => @@ -118,7 +113,7 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }; await waitFor(() => { - expect(ctx.userService.fetchUser).toHaveBeenCalledTimes(1); + expect(teleCtx.userService.fetchUser).toHaveBeenCalledTimes(1); }); act(() => { @@ -126,18 +121,18 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); + expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. const mockUser = getMockUser(); - expect(ctx.userService.updateUser).toHaveBeenCalledWith({ + expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ ...mockUser, traits: { ...mockUser.traits, ...expected }, }); // Test that updating meta correctly updated the dynamic traits. - const updatedMeta = props.updateAgentMeta.mock.results[0].value as KubeMeta; + const updatedMeta = spyUpdateAgentMeta.mock.results[0].value as KubeMeta; expect(updatedMeta.kube.users).toStrictEqual([ ...staticTraits.kubeUsers, ...expected.kubeUsers, @@ -149,15 +144,19 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); test('database', async () => { - const props = { - agentMeta: getMeta(ResourceKind.Database) as AgentMeta, - updateAgentMeta: jest.fn(x => x), - nextStep: () => null, - resourceSpec: { kind: ResourceKind.Database } as any, + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(ResourceKind.Database), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(ResourceKind.Database), }; + const spyUpdateAgentMeta = jest + .spyOn(discoverCtx, 'updateAgentMeta') + .mockImplementation(x => x); - const { result } = renderHook(() => useUserTraits(props), { - wrapper, + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), }); await waitFor(() => @@ -205,18 +204,18 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); + expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. const mockUser = getMockUser(); - expect(ctx.userService.updateUser).toHaveBeenCalledWith({ + expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ ...mockUser, traits: { ...mockUser.traits, ...expected }, }); // Test that updating meta correctly updated the dynamic traits. - const updatedMeta = props.updateAgentMeta.mock.results[0].value as DbMeta; + const updatedMeta = spyUpdateAgentMeta.mock.results[0].value as DbMeta; expect(updatedMeta.db.users).toStrictEqual([ ...staticTraits.databaseUsers, ...expected.databaseUsers, @@ -227,16 +226,90 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, ]); }); + test('database with auto discover preserves existing + new dynamic traits', async () => { + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(ResourceKind.Database), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(ResourceKind.Database), + autoDiscovery: { + config: { name: '', discoveryGroup: '', aws: [] }, + requiredVpcsAndSubnets: {}, + }, + }; + + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), + }); + + await waitFor(() => + expect(result.current.dynamicTraits.databaseNames).toHaveLength(2) + ); + + expect(result.current.dynamicTraits.databaseUsers).toHaveLength(2); + + // Should not be setting statics. + expect(result.current.staticTraits.databaseNames).toHaveLength(0); + expect(result.current.staticTraits.databaseUsers).toHaveLength(0); + + const addedTraitsOpts = { + databaseNames: [ + { + isFixed: true, + label: 'banana', + value: 'banana', + }, + { + isFixed: true, + label: 'carrot', + value: 'carrot', + }, + ], + databaseUsers: [ + { + isFixed: false, + label: 'apple', + value: 'apple', + }, + ], + }; + + act(() => { + result.current.onProceed(addedTraitsOpts); + }); + + await waitFor(() => { + expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1); + }); + + // Test that we are updating the user with the correct traits. + const mockUser = getMockUser(); + const { databaseUsers, databaseNames } = result.current.dynamicTraits; + expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ + ...mockUser, + traits: { + ...result.current.dynamicTraits, + databaseNames: [...databaseNames, 'banana', 'carrot'], + databaseUsers: [...databaseUsers, 'apple'], + }, + }); + }); + test('node', async () => { - const props = { - agentMeta: getMeta(ResourceKind.Server) as AgentMeta, - updateAgentMeta: jest.fn(x => x), - nextStep: () => null, - resourceSpec: { kind: ResourceKind.Server } as any, + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(ResourceKind.Server), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(ResourceKind.Server), }; + const spyUpdateAgentMeta = jest + .spyOn(discoverCtx, 'updateAgentMeta') + .mockImplementation(x => x); - const { result } = renderHook(() => useUserTraits(props), { - wrapper, + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), }); await waitFor(() => @@ -276,18 +349,18 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); + expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. const mockUser = getMockUser(); - expect(ctx.userService.updateUser).toHaveBeenCalledWith({ + expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({ ...mockUser, traits: { ...mockUser.traits, ...expected }, }); // Test that updating meta correctly updated the dynamic traits. - const updatedMeta = props.updateAgentMeta.mock.results[0].value as NodeMeta; + const updatedMeta = spyUpdateAgentMeta.mock.results[0].value as NodeMeta; expect(updatedMeta.node.sshLogins).toStrictEqual([ ...staticTraits.logins, ...expected.logins, @@ -304,31 +377,26 @@ describe('static and dynamic traits are correctly separated and correctly create ${ResourceKind.Database} | ${'databaseNames'} ${ResourceKind.Database} | ${'databaseUsers'} `('$traitName', async ({ resourceKind, traitName }) => { - const ctx = createTeleportContext(); - jest.spyOn(ctx.userService, 'fetchUser').mockResolvedValue(getMockUser()); - - const props = { - agentMeta: getMeta(resourceKind) as AgentMeta, - updateAgentMeta: () => null, - nextStep: () => null, - resourceSpec: { kind: resourceKind } as any, - }; + const teleCtx = createTeleportContext(); + jest + .spyOn(teleCtx.userService, 'fetchUser') + .mockResolvedValue(getMockUser()); - const wrapper = ({ children }) => ( - - - - {children} - - - - ); + const discoverCtx = defaultDiscoverContext({ + resourceSpec: defaultResourceSpec(resourceKind), + }); + discoverCtx.agentMeta = { + ...discoverCtx.agentMeta, + ...getMeta(resourceKind), + }; - const { result } = renderHook(() => useUserTraits(props), { - wrapper, + const { result } = renderHook(() => useUserTraits(), { + wrapper: wrapperFn(discoverCtx, teleCtx), }); - await waitFor(() => expect(ctx.userService.fetchUser).toHaveBeenCalled()); + await waitFor(() => + expect(teleCtx.userService.fetchUser).toHaveBeenCalled() + ); // Test correct making of dynamic traits. const dynamicTraits = result.current.dynamicTraits; @@ -453,3 +521,18 @@ function getMeta(resource: ResourceKind) { } as DbMeta; } } + +function wrapperFn( + discoverCtx: DiscoverContextState, + teleportCtx: TeleportContext +) { + return ({ children }) => ( + + + + {children} + + + + ); +} diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts index e84bf1acab77d..a6e8ce4fea85d 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts @@ -28,7 +28,6 @@ import { ResourceKind } from '../ResourceKind'; import type { DbMeta, KubeMeta, NodeMeta } from 'teleport/Discover/useDiscover'; import type { User, UserTraits } from 'teleport/services/user'; -import type { AgentStepProps } from '../../types'; // useUserTraits handles: // - retrieving the latest user (for the dynamic traits) from the backend @@ -36,9 +35,16 @@ import type { AgentStepProps } from '../../types'; // - updating user in the backend with the latest dynamic traits // - updating the dynamic traits for our in-memory resource meta object // - provides utility function that makes data objects (type Option) for react-select component -export function useUserTraits(props: AgentStepProps) { +export function useUserTraits() { const ctx = useTeleport(); - const { emitErrorEvent } = useDiscover(); + const { + emitErrorEvent, + agentMeta, + resourceSpec, + updateAgentMeta, + nextStep: next, + prevStep, + } = useDiscover(); const [user, setUser] = useState(); const { attempt, run, setAttempt, handleError } = useAttempt('processing'); @@ -46,16 +52,16 @@ export function useUserTraits(props: AgentStepProps) { const isSsoUser = ctx.storeUser.state.authType === 'sso'; const canEditUser = ctx.storeUser.getUserAccess().edit; const dynamicTraits = initUserTraits(user); + const wantAutoDiscover = !!agentMeta.autoDiscovery; // Filter out static traits from the resource that we // queried in a prior step where we discovered the newly connected resource. // The resource itself contains traits that define both // dynamic (user-defined) and static (role-defined) traits. - let meta = props.agentMeta; let staticTraits = initUserTraits(); - switch (props.resourceSpec.kind) { + switch (resourceSpec.kind) { case ResourceKind.Kubernetes: - const kube = (meta as KubeMeta).kube; + const kube = (agentMeta as KubeMeta).kube; staticTraits.kubeUsers = arrayStrDiff( kube.users, dynamicTraits.kubeUsers @@ -67,25 +73,27 @@ export function useUserTraits(props: AgentStepProps) { break; case ResourceKind.Server: - const node = (meta as NodeMeta).node; + const node = (agentMeta as NodeMeta).node; staticTraits.logins = arrayStrDiff(node.sshLogins, dynamicTraits.logins); break; case ResourceKind.Database: - const db = (meta as DbMeta).db; - staticTraits.databaseUsers = arrayStrDiff( - db.users, - dynamicTraits.databaseUsers - ); - staticTraits.databaseNames = arrayStrDiff( - db.names, - dynamicTraits.databaseNames - ); + if (!wantAutoDiscover) { + const db = (agentMeta as DbMeta).db; + staticTraits.databaseUsers = arrayStrDiff( + db.users, + dynamicTraits.databaseUsers + ); + staticTraits.databaseNames = arrayStrDiff( + db.names, + dynamicTraits.databaseNames + ); + } break; default: throw new Error( - `useUserTraits.ts:statiTraits: resource kind ${props.resourceSpec.kind} is not handled` + `useUserTraits.ts:statiTraits: resource kind ${resourceSpec.kind} is not handled` ); } @@ -107,8 +115,11 @@ export function useUserTraits(props: AgentStepProps) { // onProceed deduplicates and removes static traits from the list of traits // before updating user in the backend. - function onProceed(traitOpts: Partial>) { - switch (props.resourceSpec.kind) { + function onProceed( + traitOpts: Partial>, + numStepsToIncrement?: number + ) { + switch (resourceSpec.kind) { case ResourceKind.Kubernetes: const newDynamicKubeUsers = new Set(); traitOpts.kubeUsers.forEach(o => { @@ -142,29 +153,38 @@ export function useUserTraits(props: AgentStepProps) { break; case ResourceKind.Database: - const newDynamicDbUsers = new Set(); + let newDynamicDbUsers = new Set(); + if (wantAutoDiscover) { + newDynamicDbUsers = new Set(dynamicTraits.databaseUsers); + } traitOpts.databaseUsers.forEach(o => { if (!staticTraits.databaseUsers.includes(o.value)) { newDynamicDbUsers.add(o.value); } }); - const newDynamicDbNames = new Set(); + let newDynamicDbNames = new Set(); + if (wantAutoDiscover) { + newDynamicDbNames = new Set(dynamicTraits.databaseNames); + } traitOpts.databaseNames.forEach(o => { if (!staticTraits.databaseNames.includes(o.value)) { newDynamicDbNames.add(o.value); } }); - nextStep({ - databaseUsers: [...newDynamicDbUsers], - databaseNames: [...newDynamicDbNames], - }); + nextStep( + { + databaseUsers: [...newDynamicDbUsers], + databaseNames: [...newDynamicDbNames], + }, + numStepsToIncrement + ); break; default: throw new Error( - `useUserTrait.ts:onProceed: resource kind ${props.resourceSpec.kind} is not handled` + `useUserTrait.ts:onProceed: resource kind ${resourceSpec.kind} is not handled` ); } } @@ -174,11 +194,11 @@ export function useUserTraits(props: AgentStepProps) { function updateResourceMetaDynamicTraits( newDynamicTraits: Partial ) { - let meta = props.agentMeta; - switch (props.resourceSpec.kind) { + let meta = agentMeta; + switch (resourceSpec.kind) { case ResourceKind.Kubernetes: const kube = (meta as KubeMeta).kube; - props.updateAgentMeta({ + updateAgentMeta({ ...meta, kube: { ...kube, @@ -193,7 +213,7 @@ export function useUserTraits(props: AgentStepProps) { case ResourceKind.Server: const node = (meta as NodeMeta).node; - props.updateAgentMeta({ + updateAgentMeta({ ...meta, node: { ...node, @@ -204,7 +224,7 @@ export function useUserTraits(props: AgentStepProps) { case ResourceKind.Database: const db = (meta as DbMeta).db; - props.updateAgentMeta({ + updateAgentMeta({ ...meta, db: { ...db, @@ -222,14 +242,17 @@ export function useUserTraits(props: AgentStepProps) { default: throw new Error( - `useUserTraits.ts:updateResourceMetaDynamicTraits: resource kind ${props.resourceSpec.kind} is not handled` + `useUserTraits.ts:updateResourceMetaDynamicTraits: resource kind ${resourceSpec.kind} is not handled` ); } } - async function nextStep(newDynamicTraits: Partial) { + async function nextStep( + newDynamicTraits: Partial, + numStepsToSkip?: number + ) { if (isSsoUser || !canEditUser) { - props.nextStep(); + next(); return; } @@ -255,7 +278,7 @@ export function useUserTraits(props: AgentStepProps) { throw error; }); - props.nextStep(); + next(numStepsToSkip); } catch (err) { handleError(err); } @@ -281,10 +304,10 @@ export function useUserTraits(props: AgentStepProps) { // script which wouldn't make sense to go back to. let onPrev; if ( - props.resourceSpec.kind === ResourceKind.Database && - (props.agentMeta as DbMeta).serviceDeployedMethod !== 'auto' + resourceSpec.kind === ResourceKind.Database && + (agentMeta as DbMeta).serviceDeployedMethod !== 'auto' ) { - onPrev = props.prevStep; + onPrev = prevStep; } return { @@ -299,8 +322,8 @@ export function useUserTraits(props: AgentStepProps) { getSelectableOptions, dynamicTraits, staticTraits, - resourceSpec: props.resourceSpec, - agentMeta: props.agentMeta, + resourceSpec, + agentMeta, }; } @@ -324,6 +347,7 @@ export function initSelectedOptionsHelper({ trait: Trait; staticTraits?: UserTraits; dynamicTraits?: UserTraits; + wantAutoDiscover?: boolean; }): Option[] { let fixedOptions = []; if (staticTraits) { diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index e83497a5a5205..d7761b26c392b 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -30,6 +30,7 @@ import { DiscoverServiceDeployType, } from 'teleport/services/userEvent'; import cfg from 'teleport/config'; +import { DiscoveryConfig } from 'teleport/services/discovery'; import { addIndexToViews, @@ -480,6 +481,19 @@ type BaseMeta = { * This field is set when a user wants to enroll AWS resources. */ awsRegion?: Regions; + /** + * If this field is defined, then user opted for auto discovery. + * Auto discover will automatically identify and register resources + * 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; + }; }; // NodeMeta describes the fields for node resource @@ -496,8 +510,10 @@ export type DbMeta = BaseMeta & { // The enroll event expects num count of enrolled RDS's, update accordingly. db?: Database; selectedAwsRdsDb?: AwsRdsDatabase; - // serviceDeployedMethod flag will be undefined if user skipped - // deploying service (service already existed). + /** + * serviceDeployedMethod flag will be undefined if user skipped + * deploying service (service already existed). + */ serviceDeployedMethod?: ServiceDeployMethod; }; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 1517b88ef2e3a..c01226a5738b0 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -224,6 +224,8 @@ const cfg = { nodeScriptPath: '/scripts/:token/install-node.sh', appNodeScriptPath: '/scripts/:token/install-app.sh?name=:name&uri=:uri', + discoveryConfigPath: '/v1/webapi/sites/:clusterId/discoveryconfig', + mfaRequired: '/v1/webapi/sites/:clusterId/mfa/required', mfaLoginBegin: '/v1/webapi/mfa/login/begin', // creates authnenticate challenge with user and password mfaLoginFinish: '/v1/webapi/mfa/login/finishsession', // creates a web session @@ -272,6 +274,8 @@ const cfg = { awsConfigureIamScriptEc2InstanceConnectPath: '/v1/webapi/scripts/integrations/configure/eice-iam.sh?awsRegion=:region&role=:iamRoleName', + awsRdsDbDeployServicesPath: + '/webapi/sites/:clusterId/integrations/aws-oidc/:name/deploydatabaseservices', awsRdsDbRequiredVpcsPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/requireddatabasesvpcs', awsRdsDbListPath: @@ -736,6 +740,10 @@ const cfg = { return generatePath(cfg.api.rolesPath, { name }); }, + getDiscoveryConfigUrl(clusterId: string) { + return generatePath(cfg.api.discoveryConfigPath, { clusterId }); + }, + getPresetRolesUrl() { return cfg.api.presetRolesPath; }, @@ -798,6 +806,15 @@ const cfg = { }); }, + getAwsRdsDbsDeployServicesUrl(integrationName: string) { + const clusterId = cfg.proxyCluster; + + return generatePath(cfg.api.awsRdsDbDeployServicesPath, { + clusterId, + name: integrationName, + }); + }, + getAwsDeployTeleportServiceUrl(integrationName: string) { const clusterId = cfg.proxyCluster; diff --git a/web/packages/teleport/src/lib/util.ts b/web/packages/teleport/src/lib/util.ts index 1e0e62e70ba52..8953b7983cfd8 100644 --- a/web/packages/teleport/src/lib/util.ts +++ b/web/packages/teleport/src/lib/util.ts @@ -70,8 +70,10 @@ export function generateTshLoginCommand({ } } -// arrayStrDiff returns an array of strings that -// belong in stringsA but not in stringsB. +/** + * arrayStrDiff returns an array of strings that + * belong in stringsA but not in stringsB. + */ export function arrayStrDiff(stringsA: string[], stringsB: string[]) { if (!stringsA || !stringsB) { return []; diff --git a/web/packages/teleport/src/services/discovery/discovery.ts b/web/packages/teleport/src/services/discovery/discovery.ts new file mode 100644 index 0000000000000..d458b7d27387f --- /dev/null +++ b/web/packages/teleport/src/services/discovery/discovery.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import api from 'teleport/services/api'; +import cfg from 'teleport/config'; + +import { AwsMatcher, DiscoveryConfig } from './types'; + +// the backend expected hardcoded value for field `discoveryGroup` +// when creating a discovery config. +export const DISCOVERY_GROUP_CLOUD = 'cloud-discovery-group'; + +export function createDiscoveryConfig( + clusterId: string, + req: DiscoveryConfig +): Promise { + return api + .post(cfg.getDiscoveryConfigUrl(clusterId), req) + .then(makeDiscoveryConfig); +} + +export function makeDiscoveryConfig(rawResp: DiscoveryConfig): DiscoveryConfig { + const { name, discoveryGroup, aws } = rawResp; + + return { + name, + discoveryGroup, + aws: makeAws(aws), + }; +} + +function makeAws(rawResp: AwsMatcher[]) { + if (!rawResp) { + return []; + } + + return rawResp.map(a => ({ + types: a.types || [], + regions: a.regions || [], + tags: a.tags || {}, + integration: a.integration, + })); +} diff --git a/web/packages/teleport/src/services/discovery/index.ts b/web/packages/teleport/src/services/discovery/index.ts new file mode 100644 index 0000000000000..948b825b4d062 --- /dev/null +++ b/web/packages/teleport/src/services/discovery/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './discovery'; +export * from './types'; diff --git a/web/packages/teleport/src/services/discovery/types.ts b/web/packages/teleport/src/services/discovery/types.ts new file mode 100644 index 0000000000000..0329e1c8e48c3 --- /dev/null +++ b/web/packages/teleport/src/services/discovery/types.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Regions } from '../integrations'; + +// DiscoveryConfig describes DiscoveryConfig fields. +// Used for auto discovery service. +export type DiscoveryConfig = { + // name is the DiscoveryConfig name. + name: string; + // discoveryGroup is the Group of the DiscoveryConfig. + discoveryGroup: string; + // aws is a list of matchers for AWS resources. + aws: AwsMatcher[]; +}; + +type AwsMatcherDatabaseTypes = 'ec2' | 'rds'; + +// AWSMatcher matches AWS EC2 instances and AWS Databases +export type AwsMatcher = { + // types are AWS database types to match, "ec2", "rds", "redshift", "elasticache", + // or "memorydb". + types: AwsMatcherDatabaseTypes[]; + // regions are AWS regions to query for databases. + regions: Regions[]; + // tags are AWS resource tags to match. + tags: Labels; + // integration is the integration name used to generate credentials to interact with AWS APIs. + // Environment credentials will not be used when this value is set. + integration: string; +}; + +type Labels = Record; diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 074db2e030d1d..22dc30bf8ee30 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -32,7 +32,6 @@ import { ListAwsRdsDatabaseResponse, RdsEngineIdentifier, AwsOidcDeployServiceRequest, - AwsOidcDeployServiceResponse, ListEc2InstancesRequest, ListEc2InstancesResponse, Ec2InstanceConnectEndpoint, @@ -43,6 +42,7 @@ import { DeployEc2InstanceConnectEndpointRequest, DeployEc2InstanceConnectEndpointResponse, SecurityGroup, + AwsOidcDeployDatabaseServicesRequest, } from './types'; export const integrationService = { @@ -142,8 +142,19 @@ export const integrationService = { deployAwsOidcService( integrationName, req: AwsOidcDeployServiceRequest - ): Promise { - return api.post(cfg.getAwsDeployTeleportServiceUrl(integrationName), req); + ): Promise { + return api + .post(cfg.getAwsDeployTeleportServiceUrl(integrationName), req) + .then(resp => resp.serviceDashboardUrl); + }, + + deployDatabaseServices( + integrationName, + req: AwsOidcDeployDatabaseServicesRequest + ): Promise { + return api + .post(cfg.getAwsRdsDbsDeployServicesUrl(integrationName), req) + .then(resp => resp.clusterDashboardUrl); }, // Returns a list of EC2 Instances using the ListEC2ICE action of the AWS OIDC Integration. diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 1c50172bc8330..0b2543b79901a 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -263,19 +263,34 @@ export type AwsOidcDeployServiceRequest = { securityGroups?: string[]; }; -export type AwsOidcDeployServiceResponse = { - // clusterArn is the Amazon ECS Cluster ARN - // where the task was started. - clusterArn: string; - // serviceArn is the Amazon ECS Cluster Service - // ARN created to run the task. - serviceArn: string; - // taskDefinitionArn is the Amazon ECS Task Definition - // ARN created to run the Service. - taskDefinitionArn: string; - // serviceDashboardUrl is a link to the service's Dashboard - // URL in Amazon Console. - serviceDashboardUrl: string; +// DeployDatabaseServiceDeployment identifies the required fields to deploy a DatabaseService. +type DeployDatabaseServiceDeployment = { + // VPCID is the VPCID where the service is going to be deployed. + vpcId: string; + // SubnetIDs are the subnets for the network configuration. + // They must belong to the VPCID above. + subnetIds: string[]; + // SecurityGroups are the SecurityGroup IDs to associate with this particular deployment. + // If empty, the default security group for the VPC is going to be used. + // TODO(lisa): out of scope. + securityGroups?: string[]; +}; + +// AwsOidcDeployDatabaseServicesRequest contains the required fields to perform a DeployService request. +// Each deployed DatabaseService will be proxying the resources that match the following labels: +// -region: +// -account-id: s +// -vpc-id: +export type AwsOidcDeployDatabaseServicesRequest = { + // Region is the AWS Region for the Service. + region: string; + // TaskRoleARN is the AWS Role's ARN used within the Task execution. + // Ensure the AWS Client's Role has `iam:PassRole` for this Role's ARN. + // This can be either the ARN or the short name of the AWS Role. + taskRoleArn: string; + // Deployments is a list of Services to be deployed. + // If the target deployment already exists, the deployment is skipped. + deployments: DeployDatabaseServiceDeployment[]; }; export type ListEc2InstancesRequest = {