diff --git a/web/packages/shared/utils/errorType.ts b/web/packages/shared/utils/errorType.ts index 5a07e0208ee91..79e7aab24bc74 100644 --- a/web/packages/shared/utils/errorType.ts +++ b/web/packages/shared/utils/errorType.ts @@ -19,3 +19,14 @@ import { privateKeyEnablingPolicies } from 'shared/services'; export function isPrivateKeyRequiredError(err: Error) { return privateKeyEnablingPolicies.some(p => err.message.includes(p)); } + +// getErrMessage first checks if the error is of type Error +// before attempting to access the error message field. +// Used with try catch blocks, where the error caught +// may not necessary be of type Error. +export function getErrMessage(err: unknown) { + let message = 'something went wrong'; + if (err instanceof Error) message = err.message; + + return message; +} 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 636d20e50aa6f..f917d194cea72 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx @@ -68,6 +68,7 @@ const props: State = { attempt: { status: '' }, clearAttempt: () => null, registerDatabase: () => null, + fetchDatabaseServers: () => null, canCreateDatabase: true, pollTimeout: Date.now() + 30000, dbEngine: DatabaseEngine.Postgres, diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx index 547adb2a5c858..8df99e4c72b02 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx @@ -52,7 +52,7 @@ export function CreateDatabaseDialog({ <> - Register Failed: {attempt.statusText} + {attempt.statusText} @@ -104,7 +104,7 @@ export function CreateDatabaseDialog({ return ( ( /> ); -const fixtures: AwsRdsDatabase[] = [ +const fixtures: CheckedAwsRdsDatabase[] = [ { name: 'postgres-name', engine: 'postgres', @@ -103,6 +102,7 @@ const fixtures: AwsRdsDatabase[] = [ status: 'available', accountId: '', resourceId: '', + dbServerExists: true, }, { name: 'alpaca', @@ -137,6 +137,7 @@ const fixtures: AwsRdsDatabase[] = [ status: 'Unknown' as any, accountId: '', resourceId: '', + dbServerExists: true, }, { name: 'llama', diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index b7ed9612bc2dd..d9a1aceae14fe 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -20,16 +20,17 @@ import { FetchStatus } from 'design/DataTable/types'; import { Danger } from 'design/Alert'; import useAttempt from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; import { AwsRdsDatabase, - ListAwsRdsDatabaseResponse, RdsEngineIdentifier, Regions, integrationService, } from 'teleport/services/integrations'; import { DatabaseEngine } from 'teleport/Discover/SelectResource'; +import { Database } from 'teleport/services/databases'; import { ActionButtons, Header } from '../../Shared'; @@ -40,7 +41,7 @@ import { AwsRegionSelector } from './AwsRegionSelector'; import { DatabaseList } from './RdsDatabaseList'; type TableData = { - items: ListAwsRdsDatabaseResponse['databases']; + items: CheckedAwsRdsDatabase[]; fetchStatus: FetchStatus; startKey?: string; currRegion?: Regions; @@ -52,6 +53,14 @@ const emptyTableData: TableData = { 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 EnrollRdsDatabase() { const { createdDb, @@ -60,6 +69,7 @@ export function EnrollRdsDatabase() { attempt: registerAttempt, clearAttempt: clearRegisterAttempt, nextStep, + fetchDatabaseServers, } = useCreateDatabase(); const { agentMeta, resourceSpec, emitErrorEvent } = useDiscover(); @@ -71,7 +81,7 @@ export function EnrollRdsDatabase() { startKey: '', fetchStatus: 'disabled', }); - const [selectedDb, setSelectedDb] = useState(); + const [selectedDb, setSelectedDb] = useState(); function fetchDatabasesWithNewRegion(region: Regions) { // Clear table when fetching with new region. @@ -87,36 +97,69 @@ export function EnrollRdsDatabase() { fetchDatabases({ ...tableData, startKey: '', items: [] }); } - function fetchDatabases(data: TableData) { + async function fetchDatabases(data: TableData) { const integrationName = (agentMeta as DbMeta).integrationName; setTableData({ ...data, fetchStatus: 'loading' }); setFetchDbAttempt({ status: 'processing' }); - integrationService - .fetchAwsRdsDatabases( - integrationName, - getRdsEngineIdentifier(resourceSpec.dbMeta?.engine), - { - region: data.currRegion, - nextToken: data.startKey, + try { + const { databases: fetchedRdsDbs, nextToken } = + await integrationService.fetchAwsRdsDatabases( + integrationName, + getRdsEngineIdentifier(resourceSpec.dbMeta?.engine), + { + region: data.currRegion, + nextToken: data.startKey, + } + ); + + // 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, + }; } - ) - .then(resp => { - setFetchDbAttempt({ status: 'success' }); - setTableData({ - currRegion: data.currRegion, - startKey: resp.nextToken, - fetchStatus: resp.nextToken ? '' : 'disabled', - // concat each page fetch. - items: [...data.items, ...resp.databases], - }); - }) - .catch((err: Error) => { - setFetchDbAttempt({ status: 'failed', statusText: err.message }); - setTableData(data); // fallback to previous data - emitErrorEvent(`failed to fetch aws rds list: ${err.message}`); + return rds; }); + + setFetchDbAttempt({ status: 'success' }); + setTableData({ + currRegion: data.currRegion, + startKey: nextToken, + fetchStatus: nextToken ? '' : 'disabled', + // concat each page fetch. + items: [...data.items, ...checkedRdsDbs], + }); + } catch (err) { + const errMsg = getErrMessage(err); + setFetchDbAttempt({ status: 'failed', statusText: errMsg }); + setTableData(data); // fallback to previous data + emitErrorEvent(`database fetch error: ${errMsg}`); + } } function clear() { diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx index 40a8666f3ef02..bc5d698e3f0b4 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx @@ -17,21 +17,19 @@ import React from 'react'; import styled from 'styled-components'; import { Flex, Box, Label as Pill } from 'design'; -import Table, { Cell } from 'design/DataTable'; +import Table, { Cell as TableCell } from 'design/DataTable'; import { FetchStatus } from 'design/DataTable/types'; -import { - AwsRdsDatabase, - ListAwsRdsDatabaseResponse, -} from 'teleport/services/integrations'; import { Label } from 'teleport/types'; +import { CheckedAwsRdsDatabase } from './EnrollRdsDatabase'; + type Props = { - items: ListAwsRdsDatabaseResponse['databases']; + items: CheckedAwsRdsDatabase[]; fetchStatus: FetchStatus; fetchNextPage(): void; - onSelectDatabase(item: AwsRdsDatabase): void; - selectedDatabase?: AwsRdsDatabase; + onSelectDatabase(item: CheckedAwsRdsDatabase): void; + selectedDatabase?: CheckedAwsRdsDatabase; }; export const DatabaseList = ({ @@ -55,9 +53,10 @@ export const DatabaseList = ({ return ( ); }, @@ -65,17 +64,25 @@ export const DatabaseList = ({ { key: 'name', headerText: 'Name', - render: ({ name }) => {name}, + render: ({ name, dbServerExists }) => ( + {name} + ), }, { key: 'engine', headerText: 'Engine', - render: ({ engine }) => {engine}, + render: ({ engine, dbServerExists }) => ( + {engine} + ), }, { key: 'labels', headerText: 'Labels', - render: ({ labels }) => , + render: ({ labels, dbServerExists }) => ( + + + + ), }, { key: 'status', @@ -92,11 +99,11 @@ export const DatabaseList = ({ ); }; -const StatusCell = ({ item }: { item: AwsRdsDatabase }) => { +const StatusCell = ({ item }: { item: CheckedAwsRdsDatabase }) => { const status = getStatus(item); return ( - + {item.status} @@ -109,25 +116,32 @@ function RadioCell({ item, isChecked, onChange, + disabled, }: { - item: AwsRdsDatabase; + item: CheckedAwsRdsDatabase; isChecked: boolean; - onChange(selectedItem: AwsRdsDatabase): void; + onChange(selectedItem: CheckedAwsRdsDatabase): void; + disabled: boolean; }) { return ( - + props.theme.space[2]}px 0 0; accent-color: ${props => props.theme.colors.brand.accent}; cursor: pointer; + + &:disabled { + cursor: not-allowed; + } `} type="radio" name={item.name} checked={isChecked} onChange={() => onChange(item)} value={item.name} + disabled={disabled} /> @@ -140,7 +154,7 @@ enum Status { Error, } -function getStatus(item: AwsRdsDatabase) { +function getStatus(item: CheckedAwsRdsDatabase) { switch (item.status) { case 'available': return Status.Success; @@ -172,7 +186,7 @@ const StatusLight = styled(Box)` }}; `; -const LabelCell = ({ labels }: { labels: Label[] }) => { +const Labels = ({ labels }: { labels: Label[] }) => { const $labels = labels.map((label, index) => { const labelText = `${label.name}: ${label.value}`; @@ -183,11 +197,7 @@ const LabelCell = ({ labels }: { labels: Label[] }) => { ); }); - return ( - - {$labels} - - ); + return {$labels}; }; // labelMatcher allows user to client search by labels in the format @@ -197,7 +207,7 @@ const LabelCell = ({ labels }: { labels: Label[] }) => { function labelMatcher( targetValue: any, searchValue: string, - propName: keyof AwsRdsDatabase & string + propName: keyof CheckedAwsRdsDatabase & string ) { if (propName === 'labels') { return targetValue.some((label: Label) => { @@ -211,3 +221,25 @@ function labelMatcher( }); } } + +const Cell: React.FC<{ disabled: boolean; width?: string }> = ({ + disabled, + width, + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index ec7db069ef4e6..754b01c10d7d8 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -63,6 +63,7 @@ type EventState = { }; type CustomEventInput = { + id?: string; eventName?: DiscoverEvent; eventResourceName?: DiscoverEventResource; autoDiscoverResourcesCount?: number; @@ -116,7 +117,7 @@ export function DiscoverProvider( userEventService.captureDiscoverEvent({ event: custom?.eventName || currEventName, eventData: { - id, + id: id || custom.id, resource: custom?.eventResourceName || resourceSpec?.event, autoDiscoverResourcesCount: custom?.autoDiscoverResourcesCount, selectedResourcesCount: custom?.selectedResourcesCount, @@ -213,6 +214,7 @@ export function DiscoverProvider( { eventName: discover.eventState.currEventName, eventResourceName: discover.resourceSpec.event, + id: discover.eventState.id, } ); } diff --git a/web/packages/teleport/src/services/api/parseError.js b/web/packages/teleport/src/services/api/parseError.js deleted file mode 100644 index 697caa30d682b..0000000000000 --- a/web/packages/teleport/src/services/api/parseError.js +++ /dev/null @@ -1,21 +0,0 @@ -export default function parseError(json) { - let msg = ''; - - if (json && json.error) { - msg = json.error.message; - } else if (json && json.message) { - msg = json.message; - } else if (json.responseText) { - msg = json.responseText; - } - return msg; -} - -export class ApiError extends Error { - constructor(message, response) { - message = message || 'Unknown error'; - super(message); - this.response = response; - this.name = 'ApiError'; - } -} diff --git a/web/packages/teleport/src/services/api/parseError.ts b/web/packages/teleport/src/services/api/parseError.ts new file mode 100644 index 0000000000000..fd922ef3b156e --- /dev/null +++ b/web/packages/teleport/src/services/api/parseError.ts @@ -0,0 +1,39 @@ +/** + * 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 default function parseError(json) { + let msg = ''; + + if (json && json.error) { + msg = json.error.message; + } else if (json && json.message) { + msg = json.message; + } else if (json.responseText) { + msg = json.responseText; + } + return msg; +} + +export class ApiError extends Error { + response: Response; + + constructor(message, response: Response) { + message = message || 'Unknown error'; + super(message); + this.response = response; + this.name = 'ApiError'; + } +} diff --git a/web/packages/teleport/src/services/databases/databases.test.ts b/web/packages/teleport/src/services/databases/databases.test.ts index 333dd54ddca63..eb08c2ce36f79 100644 --- a/web/packages/teleport/src/services/databases/databases.test.ts +++ b/web/packages/teleport/src/services/databases/databases.test.ts @@ -40,6 +40,19 @@ test('correct formatting of database fetch response', async () => { { name: 'cluster', value: 'root' }, { name: 'env', value: 'aws' }, ], + aws: { + rds: { + resourceId: 'resource-id', + }, + }, + }, + { + name: 'self-hosted', + type: 'Self-hosted PostgreSQL', + protocol: 'postgres', + names: [], + users: [], + labels: [], }, ], startKey: mockResponse.startKey, @@ -150,6 +163,7 @@ test('null array fields in database services fetch response', async () => { const mockResponse = { items: [ + // aws rds { name: 'aurora', desc: 'PostgreSQL 11.6: AWS Aurora', @@ -160,6 +174,19 @@ const mockResponse = { { name: 'cluster', value: 'root' }, { name: 'env', value: 'aws' }, ], + aws: { + rds: { + resource_id: 'resource-id', + }, + }, + }, + // non-aws self-hosted + { + name: 'self-hosted', + type: 'self-hosted', + protocol: 'postgres', + uri: 'localhost:5432', + labels: [], }, ], startKey: 'mockKey', diff --git a/web/packages/teleport/src/services/databases/makeDatabase.ts b/web/packages/teleport/src/services/databases/makeDatabase.ts index 135d2c5970aa2..4597afc3eb434 100644 --- a/web/packages/teleport/src/services/databases/makeDatabase.ts +++ b/web/packages/teleport/src/services/databases/makeDatabase.ts @@ -19,10 +19,20 @@ import { formatDatabaseInfo } from 'shared/services/databases'; import { Database, DatabaseService } from './types'; export function makeDatabase(json: any): Database { - const { name, desc, protocol, type } = json; + const { name, desc, protocol, type, aws } = json; const labels = json.labels || []; + // Only setting RDS fields for now. + let rds; + if (aws && aws.rds) { + rds = { + rds: { + resourceId: aws.rds.resource_id, + }, + }; + } + return { name, description: desc, @@ -32,6 +42,7 @@ export function makeDatabase(json: any): Database { names: json.database_names || [], users: json.database_users || [], hostname: json.hostname, + aws: rds, }; } diff --git a/web/packages/teleport/src/services/databases/types.ts b/web/packages/teleport/src/services/databases/types.ts index c7e6e8490c299..469927a1559c4 100644 --- a/web/packages/teleport/src/services/databases/types.ts +++ b/web/packages/teleport/src/services/databases/types.ts @@ -20,6 +20,10 @@ import { AgentLabel } from 'teleport/services/agents'; import { RdsEngine } from '../integrations'; +export type Aws = { + rds?: { resourceId: string }; +}; + export interface Database { name: string; description: string; @@ -29,6 +33,7 @@ export interface Database { names?: string[]; users?: string[]; hostname: string; + aws?: Aws; } export type DatabasesResponse = {