From bb4f1c138a573941f290285fcbdfe6fe84ced120 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Mon, 17 Apr 2023 08:00:39 -0700 Subject: [PATCH 1/6] WebDiscover: Create Enroll a RDS Database Screen (#24509) --- .../shared/components/Select/types.ts | 10 +- .../EnrollRdsDatabase/AwsRegionSelector.tsx | 98 ++++++++++++ .../EnrollRdsDatabase/DatabaseList.tsx | 109 +++++++++++++ .../EnrollRdsDatabase.story.tsx | 81 ++++++++++ .../EnrollRdsDatabase/EnrollRdsDatabase.tsx | 145 ++++++++++++++++++ .../Database/EnrollRdsDatabase/index.ts | 17 ++ web/packages/teleport/src/config.ts | 19 +++ .../src/services/integrations/integrations.ts | 34 ++++ .../src/services/integrations/types.ts | 56 +++++++ 9 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/index.ts diff --git a/web/packages/shared/components/Select/types.ts b/web/packages/shared/components/Select/types.ts index 22af654849fd7..0b48d4b9209e8 100644 --- a/web/packages/shared/components/Select/types.ts +++ b/web/packages/shared/components/Select/types.ts @@ -25,14 +25,14 @@ export type Props = { hideSelectedOptions?: boolean; controlShouldRenderValue?: boolean; maxMenuHeight?: number; - onChange(e: Option | Option[]): void; + onChange(e: Option | Option[]): void; onKeyDown?(e: KeyboardEvent): void; - value: null | Option | Option[]; + value: null | Option | Option[]; isMulti?: boolean; autoFocus?: boolean; label?: string; placeholder?: string; - options: Option[]; + options: Option[]; width?: string | number; menuPlacement?: string; minMenuHeight?: number; @@ -53,11 +53,11 @@ export type AsyncProps = Omit & { }; // Option defines the data type for select dropdown list. -export type Option = { +export type Option = { // value is the actual value used inlieu of label. value: T; // label is the value user sees in the select options dropdown. - label: string; + label: S; }; export type ActionMeta = { diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx new file mode 100644 index 0000000000000..0965d0245330c --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx @@ -0,0 +1,98 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Box, ButtonPrimary, Text, Flex } from 'design'; +import FieldSelect from 'shared/components/FieldSelect'; +import { Option } from 'shared/components/Select'; +import { requiredField } from 'shared/components/Validation/rules'; +import Validation, { Validator } from 'shared/components/Validation'; + +import { awsRegionMap, Regions } from 'teleport/services/integrations'; + +export function AwsRegionSelector({ + onFetch, + disableBtn, + disableSelector, + clear, +}: { + onFetch(region: Regions): void; + disableBtn: boolean; + disableSelector: boolean; + clear(): void; +}) { + const [selectedRegion, setSelectedRegion] = useState(); + + function handleFetch(validator: Validator) { + if (!validator.validate()) { + return; + } + onFetch(selectedRegion.value); + } + + function handleRegionSelect(option: RegionOption) { + clear(); + setSelectedRegion(option); + } + + return ( + + {({ validator }) => ( + <> + + Select the AWS Region you would like to see databases for: + + + + + + handleFetch(validator)} + width="160px" + height="40px" + mt={1} + > + Fetch Databases + + + + )} + + ); +} + +type RegionOption = Option; + +const options: RegionOption[] = Object.keys(awsRegionMap).map(region => ({ + value: region as Regions, + label: ( + +
{awsRegionMap[region]}  
+
{region}
+
+ ), +})); diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx new file mode 100644 index 0000000000000..b615d18655458 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx @@ -0,0 +1,109 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Flex } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { FetchStatus } from 'design/DataTable/types'; + +import { + AwsDatabase, + ListAwsDatabaseResponse, +} from 'teleport/services/integrations'; + +type Props = { + items: ListAwsDatabaseResponse['databases']; + fetchStatus: FetchStatus; + fetchNextPage(): void; + onSelectDatabase(item: AwsDatabase): void; + selectedDatabase?: AwsDatabase; +}; + +export const DatabaseList = ({ + items = [], + fetchStatus = '', + fetchNextPage, + onSelectDatabase, + selectedDatabase, +}: Props) => { + return ( + { + const isChecked = + item.name === selectedDatabase?.name && + item.engine === selectedDatabase?.engine; + return ( + + ); + }, + }, + { + key: 'name', + headerText: 'Name', + render: ({ name }) => {name}, + }, + { + key: 'engine', + headerText: 'Engine', + render: ({ engine }) => {engine}, + }, + ]} + emptyText="No Results" + pagination={{ pageSize: 10 }} + fetching={{ onFetchMore: fetchNextPage, fetchStatus }} + isSearchable + /> + ); +}; + +function RadioCell({ + item, + isChecked, + onChange, +}: { + item: AwsDatabase; + isChecked: boolean; + onChange(selectedItem: AwsDatabase): void; +}) { + return ( + + + props.theme.space[2]}px 0 0; + accent-color: ${props => props.theme.colors.brand.accent}; + cursor: pointer; + `} + type="radio" + name={item.name} + checked={isChecked} + onChange={() => onChange(item)} + value={item.name} + /> + + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx new file mode 100644 index 0000000000000..7bcaa141b6d0e --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx @@ -0,0 +1,81 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { AwsRegionSelector } from './AwsRegionSelector'; +import { DatabaseList } from './DatabaseList'; + +export default { + title: 'Teleport/Discover/Database/EnrollRds', +}; + +export const AwsRegionsSelectorDisabled = () => ( + null} + disableBtn={true} + disableSelector={true} + clear={() => null} + /> +); + +export const AwsRegionsSelectorEnabled = () => ( + null} + disableBtn={false} + disableSelector={false} + clear={() => null} + /> +); + +export const RdsDatabaseList = () => ( + null} + onSelectDatabase={() => null} + selectedDatabase={null} + fetchStatus="disabled" + /> +); + +export const RdsDatabaseListWithSelection = () => ( + null} + onSelectDatabase={() => null} + selectedDatabase={fixtures[2]} + fetchStatus="" + /> +); + +export const RdsDatabaseListLoading = () => ( + null} + onSelectDatabase={() => null} + selectedDatabase={fixtures[2]} + fetchStatus="loading" + /> +); + +const fixtures = [ + { name: 'postgres-name', engine: 'postgres', endpoint: '' }, + { name: 'mysql-name', engine: 'mysql', endpoint: '' }, + { name: 'alpaca', engine: 'postgres', endpoint: '' }, + { name: 'banana', engine: 'postgres', endpoint: '' }, + { name: 'watermelon', engine: 'mysql', endpoint: '' }, + { name: 'llama', engine: 'postgres', endpoint: '' }, +]; diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx new file mode 100644 index 0000000000000..d784b2761c847 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -0,0 +1,145 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Box } from 'design'; +import { FetchStatus } from 'design/DataTable/types'; +import { Danger } from 'design/Alert'; + +import useAttempt from 'shared/hooks/useAttemptNext'; + +import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { + AwsDatabase, + ListAwsDatabaseResponse, + Regions, + integrationService, +} from 'teleport/services/integrations'; + +import { ActionButtons, Header } from '../../Shared'; + +import { AwsRegionSelector } from './AwsRegionSelector'; +import { DatabaseList } from './DatabaseList'; + +type TableData = { + items: ListAwsDatabaseResponse['databases']; + fetchStatus: FetchStatus; + startKey?: string; + currRegion?: Regions; +}; + +const emptyTableData: TableData = { + items: [], + fetchStatus: 'disabled', + startKey: '', +}; + +// TODO(lisa): need to add a new event for this, or can +// we re-use "create database event"? +export function EnrollRdsDatabase() { + const { nextStep, agentMeta } = useDiscover(); + const { attempt, setAttempt } = useAttempt(''); + + const [tableData, setTableData] = useState({ + items: [], + startKey: '', + fetchStatus: 'disabled', + }); + const [selectedDb, setSelectedDb] = useState(); + + function fetchDatabasesWithNewRegion(region: Regions) { + // Clear table when fetching with new region. + fetchDatabases({ ...emptyTableData, currRegion: region }); + } + + function fetchNextPage() { + fetchDatabases({ ...tableData }); + } + + function fetchDatabases(data: TableData) { + const integrationName = (agentMeta as DbMeta).awsIntegrationName; + + setTableData({ ...data, fetchStatus: 'loading' }); + setAttempt({ status: 'processing' }); + + // TODO(lisa): re-visit after backend implementation is final + integrationService + .fetchAwsDatabases(integrationName, { + region: data.currRegion, + nextToken: data.startKey, + }) + .then(resp => { + setAttempt({ 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) => { + setAttempt({ status: 'failed', statusText: err.message }); + setTableData(data); // fallback to previous data + }); + } + + function handleOnProceed() { + // TODO(lisa): + // Update agent meta with the selected RDS database. + nextStep(); + } + + function clear() { + if (attempt.status === 'failed') { + setAttempt({ status: '' }); + } + if (tableData.items.length > 0) { + setTableData(emptyTableData); + } + if (selectedDb) { + setSelectedDb(null); + } + } + + return ( + +
Enroll a RDS Database
+ {attempt.status === 'failed' && ( + {attempt.statusText} + )} + 0 + } + /> + + +
+ ); +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/index.ts b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/index.ts new file mode 100644 index 0000000000000..74dac7fbf1c00 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/index.ts @@ -0,0 +1,17 @@ +/** + * 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 { EnrollRdsDatabase } from './EnrollRdsDatabase'; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 79eb15bebfb6a..de0b35e2f0f4c 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -212,6 +212,8 @@ const cfg = { webapiPingPath: '/v1/webapi/ping', integrationsPath: '/v1/webapi/sites/:clusterId/integrations/:name?', + integrationExecutePath: + '/v1/webapi/sites/:clusterId/integrations/:name/action/:action', userGroupsListPath: '/v1/webapi/sites/:clusterId/user-groups?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', @@ -618,6 +620,15 @@ const cfg = { }); }, + getIntegrationExecuteUrl(params: UrlIntegrationExecuteRequestParams) { + const clusterId = cfg.proxyCluster; + + return generatePath(cfg.api.integrationExecutePath, { + clusterId, + ...params, + }); + }, + getUIConfig() { return cfg.ui; }, @@ -706,4 +717,12 @@ export interface UrlResourcesParams { searchAsRoles?: 'yes' | ''; } +export interface UrlIntegrationExecuteRequestParams { + // name is the name of integration to execute (use). + name: string; + // action is the expected backend string value + // used to describe what to use the integration for. + action: 'list_databases'; +} + export default cfg; diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index b94111b43e425..88d3d63f6ff22 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -22,6 +22,9 @@ import { IntegrationCreateRequest, IntegrationStatusCode, IntegrationListResponse, + IntegrationExecuteRequest, + AwsDatabase, + ListAwsDatabaseResponse, } from './types'; export const integrationService = { @@ -50,6 +53,27 @@ export const integrationService = { deleteIntegration(name: string): Promise { return api.delete(cfg.getIntegrationsUrl(name)); }, + + fetchAwsDatabases( + integrationName, + req: IntegrationExecuteRequest + ): Promise { + return api + .post( + cfg.getIntegrationExecuteUrl({ + name: integrationName, + action: 'list_databases', + }), + req + ) + .then(json => { + const dbs = json?.databases; + return { + databases: dbs.map(makeAwsDatabase), + nextToken: json?.nextToken, + }; + }); + }, }; export function makeIntegrations(json: any): Integration[] { @@ -75,3 +99,13 @@ function makeIntegration(json: any): Integration { statusCode: IntegrationStatusCode.Running, }; } + +export function makeAwsDatabase(json: any): AwsDatabase { + json = json ?? {}; + const { engine, name, endpoint } = json; + return { + engine, + name, + endpoint, + }; +} diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 9a6bb57e4ce06..785b0564044d7 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -102,3 +102,59 @@ export type IntegrationListResponse = { items: Integration[]; nextKey?: string; }; + +// awsRegionMap maps the AWS regions to it's region name +// as defined in (omitted gov cloud regions): +// https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html +export const awsRegionMap = { + 'us-east-2': 'US East (Ohio)', + 'us-east-1': 'US East (N. Virginia)', + 'us-west-1': 'US West (N. California)', + 'us-west-2': 'US West (Oregon)', + 'af-south-1': 'Africa (Cape Town)', + 'ap-east-1': 'Asia Pacific (Hong Kong)', + 'ap-south-2': 'Asia Pacific (Hyderabad)', + 'ap-southeast-3': 'Asia Pacific (Jakarta)', + 'ap-southeast-4': 'Asia Pacific (Melbourne)', + 'ap-south-1': 'Asia Pacific (Mumbai)', + 'ap-northeast-3': 'Asia Pacific (Osaka)', + 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-southeast-1': 'Asia Pacific (Singapore)', + 'ap-southeast-2': 'Asia Pacific (Sydney)', + 'ap-northeast-1': 'Asia Pacific (Tokyo)', + 'ca-central-1': 'Canada (Central)', + 'eu-central-1': 'Europe (Frankfurt)', + 'eu-west-1': 'Europe (Ireland)', + 'eu-west-2': 'Europe (London)', + 'eu-south-1': 'Europe (Milan)', + 'eu-west-3': 'Europe (Paris)', + 'eu-south-2': 'Europe (Spain)', + 'eu-north-1': 'Europe (Stockholm)', + 'eu-central-2': 'Europe (Zurich)', + 'me-south-1': 'Middle East (Bahrain)', + 'me-central-1': 'Middle East (UAE)', + 'sa-east-1': 'South America (São Paulo)', +}; + +export type Regions = keyof typeof awsRegionMap; +export type IntegrationExecuteRequest = { + region: Regions; + // nextToken is the start key for the next page + nextToken?: string; +}; + +export type AwsDatabase = { + // engine of the database. Eg, sqlserver-ex + engine: string; + // name is the the Database's name. + name: string; + // endpoint contains the URI for connecting to this Database + endpoint: string; +}; + +export type ListAwsDatabaseResponse = { + databases: AwsDatabase[]; + // nextToken is the start key for the next page. + // Empty value means last page. + nextToken?: string; +}; From 2f76da0761b5057e1e56a46d506721fa9c81bedb Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Mon, 24 Apr 2023 12:48:00 -0700 Subject: [PATCH 2/6] WebDiscover: Finish implementing Enroll Database Screen (#24710) --- .../ConnectAwsAccount/ConnectAwsAccount.tsx | 18 +- .../CreateDatabase/CreateDatabase.story.tsx | 8 +- .../CreateDatabase/CreateDatabase.tsx | 220 +---------------- .../CreateDatabaseDialog.story.tsx | 48 ++++ .../CreateDatabase/CreateDatabaseDialog.tsx | 121 ++++++++++ .../CreateDatabase/useCreateDatabase.test.tsx | 165 ++++++++----- .../CreateDatabase/useCreateDatabase.ts | 113 ++++----- .../EnrollRdsDatabase/DatabaseList.tsx | 109 --------- .../EnrollRdsDatabase.story.tsx | 73 +++++- .../EnrollRdsDatabase/EnrollRdsDatabase.tsx | 119 ++++++--- .../EnrollRdsDatabase/RdsDatabaseList.tsx | 213 ++++++++++++++++ .../Database/SetupAccess/SetupAccess.tsx | 10 +- .../src/Discover/SelectResource/types.ts | 2 + .../teleport/src/Discover/useDiscover.tsx | 15 +- .../src/Discover/yamlTemplates/index.ts | 4 +- ...ionRWE.yaml => integrationRWEAndDbCU.yaml} | 5 + web/packages/teleport/src/config.ts | 15 +- .../teleport/src/services/databases/types.ts | 4 +- .../integrations/integrations.test.ts | 227 ++++++++++++++++++ .../src/services/integrations/integrations.ts | 72 ++++-- .../src/services/integrations/types.ts | 59 ++++- 21 files changed, 1102 insertions(+), 518 deletions(-) create mode 100644 web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx create mode 100644 web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx delete mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx create mode 100644 web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx rename web/packages/teleport/src/Discover/yamlTemplates/{integrationRWE.yaml => integrationRWEAndDbCU.yaml} (64%) create mode 100644 web/packages/teleport/src/services/integrations/integrations.test.ts diff --git a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx index 1964a2e464482..b622259338732 100644 --- a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx +++ b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx @@ -38,7 +38,7 @@ import { IntegrationKind, integrationService, } from 'teleport/services/integrations'; -import { integrationRWE } from 'teleport/Discover/yamlTemplates'; +import { integrationRWEAndDbCU } from 'teleport/Discover/yamlTemplates'; import useTeleport from 'teleport/useTeleport'; import { ActionButtons, HeaderSubtitle, HeaderWithBackBtn } from '../../Shared'; @@ -50,10 +50,14 @@ export function ConnectAwsAccount() { const { prevStep, nextStep, agentMeta, updateAgentMeta, eventState } = useDiscover(); - // TODO(lisa): also need to check for verb `use` which is pending - // work. - const access = storeUser.getIntegrationsAccess(); - const hasAccess = access.create && access.list; + const integrationAccess = storeUser.getIntegrationsAccess(); + const databaseAccess = storeUser.getDatabaseAccess(); + const hasAccess = + integrationAccess.create && + integrationAccess.list && + // Required access after integrating: + integrationAccess.use && // required to list AWS RDS db's + databaseAccess.create; // required to enroll AWS RDS db const { attempt, run } = useAttempt(hasAccess ? 'processing' : ''); const [awsIntegrations, setAwsIntegrations] = useState([]); @@ -96,7 +100,7 @@ export function ConnectAwsAccount() { @@ -134,7 +138,7 @@ export function ConnectAwsAccount() { updateAgentMeta({ ...(agentMeta as DbMeta), - awsIntegrationName: selectedAwsIntegration.value, + integrationName: selectedAwsIntegration.value, }); // TODO(lisa): Need to add a new event to emit for this screen. 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 386ed74130787..636d20e50aa6f 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.story.tsx @@ -39,12 +39,6 @@ export const InitSelfHostedMySql = () => ( ); -export const InitAws = () => ( - - - -); - export const NoPerm = () => ( @@ -80,4 +74,6 @@ const props: State = { dbLocation: DatabaseLocation.SelfHosted, isDbCreateErr: false, prevStep: () => null, + nextStep: () => null, + createdDb: {} as any, }; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx index f07dd5f2869ff..c468e0e3460cd 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx @@ -15,48 +15,33 @@ */ import React, { useState, useEffect } from 'react'; -import { - Text, - Box, - Flex, - AnimatedProgressBar, - ButtonPrimary, - ButtonSecondary, -} from 'design'; +import { Text, Box, Flex } from 'design'; -import Dialog, { DialogContent } from 'design/DialogConfirmation'; -import * as Icons from 'design/Icon'; import Validation, { Validator } from 'shared/components/Validation'; import FieldInput from 'shared/components/FieldInput'; import { requiredField } from 'shared/components/Validation/rules'; import TextEditor from 'shared/components/TextEditor'; -import { Timeout } from 'teleport/Discover/Shared/Timeout'; - import { ActionButtons, HeaderSubtitle, LabelsCreater, Mark, - TextIcon, HeaderWithBackBtn, } from '../../Shared'; import { dbCU } from '../../yamlTemplates'; import { - DatabaseLocation, getDatabaseProtocol, getDefaultDatabasePort, } from '../../SelectResource'; import { useCreateDatabase, State } from './useCreateDatabase'; +import { CreateDatabaseDialog } from './CreateDatabaseDialog'; -import type { AgentStepProps } from '../../types'; import type { AgentLabel } from 'teleport/services/agents'; -import type { Attempt } from 'shared/hooks/useAttemptNext'; -import type { AwsRds } from 'teleport/services/databases'; -export function CreateDatabase(props: AgentStepProps) { - const state = useCreateDatabase(props); +export function CreateDatabase() { + const state = useCreateDatabase(); return ; } @@ -67,19 +52,15 @@ export function CreateDatabaseView({ canCreateDatabase, pollTimeout, dbEngine, - dbLocation, isDbCreateErr, prevStep, + nextStep, }: State) { const [dbName, setDbName] = useState(''); const [dbUri, setDbUri] = useState(''); const [labels, setLabels] = useState([]); const [dbPort, setDbPort] = useState(getDefaultDatabasePort(dbEngine)); - // TODO(lisa): refactor using ryan's example (reusable). - const [awsAccountId, setAwsAccountId] = useState(''); - const [awsResourceId, setAwsResourceId] = useState(''); - const [finishedFirstStep, setFinishedFirstStep] = useState(false); useEffect(() => { @@ -102,24 +83,14 @@ export function CreateDatabaseView({ return; } - let awsRds: AwsRds; - if (dbLocation === DatabaseLocation.Aws) { - awsRds = { - accountId: awsAccountId, - resourceId: awsResourceId, - }; - } - registerDatabase({ labels, name: dbName, uri: `${dbUri}:${dbPort}`, protocol: getDatabaseProtocol(dbEngine), - awsRds, }); } - const isAws = dbLocation === DatabaseLocation.Aws; return ( {({ validator }) => ( @@ -167,33 +138,13 @@ export function CreateDatabaseView({ setDbUri(e.target.value)} width="70%" mr={2} - toolTipContent={ - isAws ? ( - - Database location and connection information. - Typically in the format:{' '} - {`...rds.amazonaws.com`} - - ) : ( - 'Database location and connection information.' - ) - } + toolTipContent="Database location and connection information." /> - {dbLocation === DatabaseLocation.Aws && ( - <> - - setAwsAccountId(e.target.value)} - toolTipContent="A 12-digit number that uniquely identifies your AWS account." - /> - - - setAwsResourceId(e.target.value)} - toolTipContent={ - - The unique identifier for your resource. May have - the prefix db- then follow with - alphanumerics. - - } - /> - - - )} Labels (optional) @@ -263,12 +182,14 @@ export function CreateDatabaseView({ attempt.status === 'processing' || !canCreateDatabase } /> - {(attempt.status === 'processing' || attempt.status === 'failed') && ( + {attempt.status !== '' && ( handleOnProceed(validator, true /* retry */)} close={clearAttempt} + dbName={dbName} + next={nextStep} /> )} @@ -277,69 +198,6 @@ export function CreateDatabaseView({ ); } -const CreateDatabaseDialog = ({ - pollTimeout, - attempt, - retry, - close, -}: { - pollTimeout: number; - attempt: Attempt; - retry(): void; - close(): void; -}) => { - return ( - - - {attempt.status !== 'failed' ? ( - <> - {' '} - - Registering Database - - - - - - - - ) : ( - - - Database Register Failed - - - - Error: {attempt.statusText} - - - - Retry - - - Close - - - - )} - - - ); -}; - // Only allows digits with valid port range 1-65535. const requirePort = (value: string) => () => { const numberValue = parseInt(value); @@ -355,59 +213,3 @@ const requirePort = (value: string) => () => { valid: true, }; }; - -// AWS_ACC_ID_REGEX only allows digits with length 12. -export const AWS_ACC_ID_REGEX = /^\d{12}$/; -const requiredAwsAccountId = value => () => { - const isValidId = value.match(AWS_ACC_ID_REGEX); - if (!isValidId) { - return { - valid: false, - message: 'aws account id must be 12 digits', - }; - } - return { - valid: true, - }; -}; - -const requireAwsEndpoint = value => () => { - const parts = value.split('.'); - // Following possible format (bare mininum len has to be 6): - // (len 6) test.abcd.us-west-2.rds.amazonaws.com - // (len 7) test.abcd.suffix.us-west-2.rds.amazonaws.com - // (len 8) test.abcd.suffix.us-west-2.rds.amazonaws.com.cn - const hasCorrectLen = parts.length >= 6; // loosely match - if (!hasCorrectLen || !value.includes('.rds.amazonaws.com')) { - return { - valid: false, - message: 'invalid connection endpoint format', - }; - } - - return { - valid: true, - }; -}; - -// TODO(lisa): this check and the backend check does not match -// re-visit and let backend do the checking for now. -// -// // AWS_POLICY_NAME_REGEX only allows alphanumeric including the -// // following common characters: plus (+), equal (=), comma (,), -// // period (.), at (@), underscore (_), and hyphen (-). -// // As defined in: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html -// export const AWS_POLICY_NAME_REGEX = /^[\w@+=,.:/-]+$/; -// const conformNameWithAWSPolicyNameReq = value => () => { -// const isValid = value.match(AWS_POLICY_NAME_REGEX); -// if (!isValid) { -// return { -// valid: false, -// message: -// 'name must be alphanumerics, including characters such as _ @ = , . + -', -// }; -// } -// return { -// valid: true, -// }; -// }; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx new file mode 100644 index 0000000000000..5a362c4e87be8 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.story.tsx @@ -0,0 +1,48 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { + CreateDatabaseDialog, + CreateDatabaseDialogProps, +} from './CreateDatabaseDialog'; + +export default { + title: 'Teleport/Discover/Database/CreateDatabase/Dialog', +}; + +export const Processing = () => ; + +export const Failed = () => ( + +); + +export const Success = () => ( + +); + +const props: CreateDatabaseDialogProps = { + pollTimeout: 8080000000, + attempt: { status: 'processing' }, + retry: () => null, + close: () => null, + next: () => null, + dbName: 'db-name', +}; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx new file mode 100644 index 0000000000000..93db631ca4904 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabaseDialog.tsx @@ -0,0 +1,121 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + Text, + Flex, + AnimatedProgressBar, + ButtonPrimary, + ButtonSecondary, +} from 'design'; +import * as Icons from 'design/Icon'; +import Dialog, { DialogContent } from 'design/DialogConfirmation'; + +import { Timeout } from 'teleport/Discover/Shared/Timeout'; +import { TextIcon } from 'teleport/Discover/Shared'; + +import type { Attempt } from 'shared/hooks/useAttemptNext'; + +export type CreateDatabaseDialogProps = { + pollTimeout: number; + attempt: Attempt; + retry(): void; + close(): void; + next(): void; + dbName: string; +}; + +export function CreateDatabaseDialog({ + pollTimeout, + attempt, + retry, + close, + next, + dbName, +}: CreateDatabaseDialogProps) { + let content: JSX.Element; + if (attempt.status === 'failed') { + content = ( + <> + + Database Register Failed + + + + Error: {attempt.statusText} + + + + Retry + + + Close + + + + ); + } else if (attempt.status === 'processing') { + content = ( + <> + + Registering Database + + + + + + + + ); + } else { + // success + content = ( + <> + + Successfully Registered Database + + + + Database "{dbName}" successfully registered + + + Next + + + ); + } + + return ( + + + {content} + + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx index 75f83f6c63498..080a3fabe0917 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx @@ -20,11 +20,18 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { ContextProvider } from 'teleport'; -import { DiscoverProvider } from 'teleport/Discover/useDiscover'; +import { + DiscoverProvider, + DiscoverContextState, +} from 'teleport/Discover/useDiscover'; import api from 'teleport/services/api'; import { FeaturesContextProvider } from 'teleport/FeaturesContext'; import { userEventService } from 'teleport/services/userEvent'; import cfg from 'teleport/config'; +import { + DatabaseEngine, + DatabaseLocation, +} from 'teleport/Discover/SelectResource'; import { useCreateDatabase, @@ -247,13 +254,27 @@ const newDatabaseReq: CreateDatabaseRequest = { jest.useFakeTimers(); describe('registering new databases, mainly error checking', () => { - const props = { + const discoverCtx: DiscoverContextState = { agentMeta: {} as any, - updateAgentMeta: jest.fn(x => x), + currentStep: 0, nextStep: jest.fn(x => x), - resourceSpec: { dbMeta: {} } as any, + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: { + dbMeta: { + location: DatabaseLocation.Aws, + engine: DatabaseEngine.AuroraMysql, + }, + } as any, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: jest.fn(x => x), + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, }; - const ctx = createTeleportContext(); + const teleCtx = createTeleportContext(); let wrapper; @@ -264,12 +285,16 @@ describe('registering new databases, mainly error checking', () => { .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(null as never); // return value does not matter but required by ts jest - .spyOn(ctx.databaseService, 'fetchDatabases') + .spyOn(teleCtx.databaseService, 'fetchDatabases') .mockResolvedValue({ agents: [{ name: 'new-db' } as any] }); - jest.spyOn(ctx.databaseService, 'createDatabase').mockResolvedValue(null); // ret val not used - jest.spyOn(ctx.databaseService, 'updateDatabase').mockResolvedValue(null); // ret val not used jest - .spyOn(ctx.databaseService, 'fetchDatabaseServices') + .spyOn(teleCtx.databaseService, 'createDatabase') + .mockResolvedValue(null); // ret val not used + jest + .spyOn(teleCtx.databaseService, 'updateDatabase') + .mockResolvedValue(null); // ret val not used + jest + .spyOn(teleCtx.databaseService, 'fetchDatabaseServices') .mockResolvedValue({ services }); wrapper = ({ children }) => ( @@ -278,9 +303,11 @@ describe('registering new databases, mainly error checking', () => { { pathname: cfg.routes.discover, state: { entity: 'database' } }, ]} > - + - {children} + + {children} + @@ -292,34 +319,40 @@ describe('registering new databases, mainly error checking', () => { }); test('with matching service, activates polling', async () => { - const { result } = renderHook(() => useCreateDatabase(props), { + const { result } = renderHook(() => useCreateDatabase(), { wrapper, }); // Check polling hasn't started. - expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.fetchDatabases).not.toHaveBeenCalled(); await act(async () => { result.current.registerDatabase(newDatabaseReq); }); - expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); await act(async () => jest.advanceTimersByTime(3000)); - expect(ctx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); - expect(props.nextStep).toHaveBeenCalledWith(2); - expect(props.updateAgentMeta).toHaveBeenCalledWith({ + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); + expect(discoverCtx.updateAgentMeta).toHaveBeenCalledWith({ resourceName: 'db-name', agentMatcherLabels: dbLabels, db: { name: 'new-db' }, }); + + // Test the dynamic definition of nextStep is called with a number + // of steps to skip. + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(2); }); test('when there are no services, skips polling', async () => { jest - .spyOn(ctx.databaseService, 'fetchDatabaseServices') + .spyOn(teleCtx.databaseService, 'fetchDatabaseServices') .mockResolvedValue({ services: [] } as any); - const { result, waitFor } = renderHook(() => useCreateDatabase(props), { + const { result, waitFor } = renderHook(() => useCreateDatabase(), { wrapper, }); @@ -328,44 +361,48 @@ describe('registering new databases, mainly error checking', () => { }); await waitFor(() => { - expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); }); await waitFor(() => { - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( - 1 - ); + expect( + teleCtx.databaseService.fetchDatabaseServices + ).toHaveBeenCalledTimes(1); }); - - expect(props.nextStep).toHaveBeenCalledWith(); - expect(props.updateAgentMeta).toHaveBeenCalledWith({ + expect(discoverCtx.updateAgentMeta).toHaveBeenCalledWith({ resourceName: 'db-name', agentMatcherLabels: [], }); - expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + + // Test the dynamic definition of nextStep is called without + // number of steps to skip defined. + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(); }); test('when failed to create db, stops flow', async () => { - jest.spyOn(ctx.databaseService, 'createDatabase').mockRejectedValue(null); - const { result } = renderHook(() => useCreateDatabase(props), { + jest + .spyOn(teleCtx.databaseService, 'createDatabase') + .mockRejectedValue(null); + const { result } = renderHook(() => useCreateDatabase(), { wrapper, }); await act(async () => { result.current.registerDatabase({ ...newDatabaseReq, labels: [] }); }); - - expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled(); - expect(props.nextStep).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); expect(result.current.attempt.status).toBe('failed'); }); test('when failed to fetch services, stops flow and retries properly', async () => { jest - .spyOn(ctx.databaseService, 'fetchDatabaseServices') + .spyOn(teleCtx.databaseService, 'fetchDatabaseServices') .mockRejectedValue(null); - const { result } = renderHook(() => useCreateDatabase(props), { + const { result } = renderHook(() => useCreateDatabase(), { wrapper, }); @@ -373,10 +410,12 @@ describe('registering new databases, mainly error checking', () => { result.current.registerDatabase({ ...newDatabaseReq, labels: [] }); }); - expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled(); - expect(props.nextStep).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + expect(teleCtx.databaseService.fetchDatabases).not.toHaveBeenCalled(); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); expect(result.current.attempt.status).toBe('failed'); // Test retrying with same request, skips creating database since it's been already created. @@ -384,8 +423,10 @@ describe('registering new databases, mainly error checking', () => { await act(async () => { result.current.registerDatabase({ ...newDatabaseReq, labels: [] }); }); - expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled(); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.createDatabase).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); expect(result.current.attempt.status).toBe('failed'); // Test retrying with updated field, triggers create database. @@ -397,17 +438,19 @@ describe('registering new databases, mainly error checking', () => { uri: 'diff-uri', }); }); - expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled(); - expect(ctx.databaseService.updateDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.createDatabase).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.updateDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); expect(result.current.attempt.status).toBe('failed'); }); test('when polling timeout, retries properly', async () => { jest - .spyOn(ctx.databaseService, 'fetchDatabases') + .spyOn(teleCtx.databaseService, 'fetchDatabases') .mockResolvedValue({ agents: [] }); - const { result } = renderHook(() => useCreateDatabase(props), { + const { result } = renderHook(() => useCreateDatabase(), { wrapper, }); @@ -417,10 +460,12 @@ describe('registering new databases, mainly error checking', () => { act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1)); - expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled(); - expect(props.nextStep).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalled(); + expect(discoverCtx.nextStep).not.toHaveBeenCalled(); expect(result.current.attempt.status).toBe('failed'); expect(result.current.attempt.statusText).toContain('could not detect'); @@ -431,9 +476,11 @@ describe('registering new databases, mainly error checking', () => { }); act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1)); - expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled(); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled(); + expect(teleCtx.databaseService.createDatabase).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalled(); expect(result.current.attempt.status).toBe('failed'); // Test retrying with request with updated fields, updates db and fetches new services. @@ -446,10 +493,12 @@ describe('registering new databases, mainly error checking', () => { }); act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1)); - expect(ctx.databaseService.updateDatabase).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled(); - expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1); - expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled(); + expect(teleCtx.databaseService.updateDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.createDatabase).not.toHaveBeenCalled(); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalled(); expect(result.current.attempt.status).toBe('failed'); }); }); diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts index 7e97ac05fceed..f71be652de845 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts @@ -24,7 +24,6 @@ import { compareByString } from 'teleport/lib/util'; import { matchLabels, makeLabelMaps } from '../util'; -import type { AgentStepProps } from '../../types'; import type { CreateDatabaseRequest, Database as DatabaseResource, @@ -35,11 +34,18 @@ import type { DbMeta } from 'teleport/Discover/useDiscover'; export const WAITING_TIMEOUT = 30000; // 30 seconds -export function useCreateDatabase(props: AgentStepProps) { +export function useCreateDatabase() { const ctx = useTeleport(); const clusterId = ctx.storeUser.getClusterId(); const { attempt, setAttempt } = useAttempt(''); - const { emitErrorEvent } = useDiscover(); + const { + emitErrorEvent, + updateAgentMeta, + agentMeta, + nextStep, + prevStep, + resourceSpec, + } = useDiscover(); // isDbCreateErr is a flag that indicates // attempt failed from trying to create a database. @@ -59,13 +65,13 @@ export function useCreateDatabase(props: AgentStepProps) { // backend error or network failure) const [createdDb, setCreatedDb] = useState(); - const result = usePoll( + const dbPollingResult = usePoll( signal => fetchDatabaseServer(signal), - pollActive, + pollActive, // does not poll on init, since the value is false. 3000 // interval: poll every 3 seconds ); - // Handles polling timeout. + // Handles setting a timeout when polling becomes active. useEffect(() => { if (pollActive && pollTimeout > Date.now()) { const id = window.setTimeout(() => { @@ -76,6 +82,7 @@ export function useCreateDatabase(props: AgentStepProps) { } }, [pollActive, pollTimeout]); + // Handles polling timeout. useEffect(() => { if (timedOut) { // reset timer fields and set errors. @@ -96,21 +103,20 @@ export function useCreateDatabase(props: AgentStepProps) { // Handles when polling successfully gets // a response. useEffect(() => { - if (!result) return; + if (!dbPollingResult) return; setPollTimeout(null); setPollActive(false); - const numStepsToSkip = 2; - props.updateAgentMeta({ - ...(props.agentMeta as DbMeta), + updateAgentMeta({ + ...(agentMeta as DbMeta), resourceName: createdDb.name, agentMatcherLabels: createdDb.labels, - db: result, + db: dbPollingResult, }); - props.nextStep(numStepsToSkip); - }, [result]); + setAttempt({ status: 'success' }); + }, [dbPollingResult]); function fetchDatabaseServer(signal: AbortSignal) { const request = { @@ -127,7 +133,7 @@ export function useCreateDatabase(props: AgentStepProps) { }); } - async function registerDatabase(db: CreateDatabaseRequest) { + async function registerDatabase(db: CreateDatabaseRequest, newDb = false) { // Set the timeout now, because this entire registering process // should take less than WAITING_TIMEOUT. setPollTimeout(Date.now() + WAITING_TIMEOUT); @@ -135,11 +141,7 @@ export function useCreateDatabase(props: AgentStepProps) { setIsDbCreateErr(false); // Attempt creating a new Database resource. - // Handles a case where if there was a later failure point - // and user decides to change the database fields, a new database - // is created (ONLY if the database name has changed since this - // request operation is only a CREATE operation). - if (!createdDb) { + if (!createdDb || newDb) { try { await ctx.databaseService.createDatabase(clusterId, db); setCreatedDb(db); @@ -150,34 +152,8 @@ export function useCreateDatabase(props: AgentStepProps) { } } - function requiresDbUpdate() { - if (!createdDb) { - return false; - } - - if (createdDb.labels.length === db.labels.length) { - // Sort by label keys. - const a = createdDb.labels.sort((a, b) => - compareByString(a.name, b.name) - ); - const b = db.labels.sort((a, b) => compareByString(a.name, b.name)); - - for (let i = 0; i < a.length; i++) { - if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) { - return true; - } - } - } - - return ( - createdDb.uri !== db.uri || - createdDb.awsRds?.accountId !== db.awsRds?.accountId || - createdDb.awsRds?.resourceId !== db.awsRds?.resourceId - ); - } - // Check and see if database resource need to be updated. - if (requiresDbUpdate()) { + if (!newDb && requiresDbUpdate(db)) { try { await ctx.databaseService.updateDatabase(clusterId, { ...db, @@ -198,12 +174,12 @@ export function useCreateDatabase(props: AgentStepProps) { ); if (!findActiveDatabaseSvc(db.labels, services)) { - props.updateAgentMeta({ - ...(props.agentMeta as DbMeta), + updateAgentMeta({ + ...(agentMeta as DbMeta), resourceName: db.name, agentMatcherLabels: db.labels, }); - props.nextStep(); + setAttempt({ status: 'success' }); return; } } catch (err) { @@ -216,6 +192,32 @@ export function useCreateDatabase(props: AgentStepProps) { setPollActive(true); } + function requiresDbUpdate(db: CreateDatabaseRequest) { + if (!createdDb) { + return false; + } + + if (createdDb.labels.length === db.labels.length) { + // Sort by label keys. + const a = createdDb.labels.sort((a, b) => + compareByString(a.name, b.name) + ); + const b = db.labels.sort((a, b) => compareByString(a.name, b.name)); + + for (let i = 0; i < a.length; i++) { + if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) { + return true; + } + } + } + + return ( + createdDb.uri !== db.uri || + createdDb.awsRds?.accountId !== db.awsRds?.accountId || + createdDb.awsRds?.resourceId !== db.awsRds?.resourceId + ); + } + function clearAttempt() { setAttempt({ status: '' }); } @@ -223,22 +225,25 @@ export function useCreateDatabase(props: AgentStepProps) { function handleRequestError(err: Error, preErrMsg = '') { let message = 'something went wrong'; if (err instanceof Error) message = err.message; - setAttempt({ status: 'failed', statusText: message }); + setAttempt({ status: 'failed', statusText: `${preErrMsg}${message}` }); emitErrorEvent(`${preErrMsg}${message}`); } const access = ctx.storeUser.getDatabaseAccess(); - const resource = props.resourceSpec; return { + createdDb, attempt, clearAttempt, registerDatabase, canCreateDatabase: access.create, pollTimeout, - dbEngine: resource.dbMeta.engine, - dbLocation: resource.dbMeta.location, + dbEngine: resourceSpec.dbMeta.engine, + dbLocation: resourceSpec.dbMeta.location, isDbCreateErr, - prevStep: props.prevStep, + prevStep, + // If there was a result from database polling, then + // allow user to skip the next step. + nextStep: dbPollingResult ? () => nextStep(2) : () => nextStep(), }; } diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx deleted file mode 100644 index b615d18655458..0000000000000 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/DatabaseList.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Flex } from 'design'; -import Table, { Cell } from 'design/DataTable'; -import { FetchStatus } from 'design/DataTable/types'; - -import { - AwsDatabase, - ListAwsDatabaseResponse, -} from 'teleport/services/integrations'; - -type Props = { - items: ListAwsDatabaseResponse['databases']; - fetchStatus: FetchStatus; - fetchNextPage(): void; - onSelectDatabase(item: AwsDatabase): void; - selectedDatabase?: AwsDatabase; -}; - -export const DatabaseList = ({ - items = [], - fetchStatus = '', - fetchNextPage, - onSelectDatabase, - selectedDatabase, -}: Props) => { - return ( -
{ - const isChecked = - item.name === selectedDatabase?.name && - item.engine === selectedDatabase?.engine; - return ( - - ); - }, - }, - { - key: 'name', - headerText: 'Name', - render: ({ name }) => {name}, - }, - { - key: 'engine', - headerText: 'Engine', - render: ({ engine }) => {engine}, - }, - ]} - emptyText="No Results" - pagination={{ pageSize: 10 }} - fetching={{ onFetchMore: fetchNextPage, fetchStatus }} - isSearchable - /> - ); -}; - -function RadioCell({ - item, - isChecked, - onChange, -}: { - item: AwsDatabase; - isChecked: boolean; - onChange(selectedItem: AwsDatabase): void; -}) { - return ( - - - props.theme.space[2]}px 0 0; - accent-color: ${props => props.theme.colors.brand.accent}; - cursor: pointer; - `} - type="radio" - name={item.name} - checked={isChecked} - onChange={() => onChange(item)} - value={item.name} - /> - - - ); -} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx index 7bcaa141b6d0e..18e745e236cfe 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx @@ -16,8 +16,10 @@ import React from 'react'; +import { AwsRdsDatabase } from 'teleport/services/integrations'; + import { AwsRegionSelector } from './AwsRegionSelector'; -import { DatabaseList } from './DatabaseList'; +import { DatabaseList } from './RdsDatabaseList'; export default { title: 'Teleport/Discover/Database/EnrollRds', @@ -71,11 +73,66 @@ export const RdsDatabaseListLoading = () => ( /> ); -const fixtures = [ - { name: 'postgres-name', engine: 'postgres', endpoint: '' }, - { name: 'mysql-name', engine: 'mysql', endpoint: '' }, - { name: 'alpaca', engine: 'postgres', endpoint: '' }, - { name: 'banana', engine: 'postgres', endpoint: '' }, - { name: 'watermelon', engine: 'mysql', endpoint: '' }, - { name: 'llama', engine: 'postgres', endpoint: '' }, +const fixtures: AwsRdsDatabase[] = [ + { + name: 'postgres-name', + engine: 'postgres', + uri: '', + labels: [], + status: 'Available', + accountId: '', + resourceId: '', + }, + { + name: 'mysql-name', + engine: 'mysql', + uri: '', + labels: [], + status: 'Available', + accountId: '', + resourceId: '', + }, + { + name: 'alpaca', + engine: 'aurora', + uri: '', + labels: [ + { name: 'env', value: 'prod' }, + { name: 'os', value: 'windows' }, + ], + status: 'Deleting', + accountId: '', + resourceId: '', + }, + { + name: 'banana', + engine: 'postgres', + uri: '', + labels: [], + status: 'Failed', + accountId: '', + resourceId: '', + }, + { + name: 'watermelon', + engine: 'mysql', + uri: '', + labels: [ + { name: 'env', value: 'dev' }, + { name: 'os', value: 'mac' }, + { name: 'fruit', value: 'watermelon' }, + ], + status: 'Unknown' as any, + accountId: '', + resourceId: '', + }, + { + name: 'llama', + engine: 'postgres', + uri: '', + labels: [{ name: 'testing-name', value: 'testing-value' }], + status: 'Available', + accountId: '', + resourceId: '', + }, ]; diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index d784b2761c847..a33bfbbde038d 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -23,19 +23,24 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; import { - AwsDatabase, - ListAwsDatabaseResponse, + AwsRdsDatabase, + ListAwsRdsDatabaseResponse, + RdsEngineIdentifier, Regions, integrationService, } from 'teleport/services/integrations'; +import { DatabaseEngine } from 'teleport/Discover/SelectResource'; import { ActionButtons, Header } from '../../Shared'; +import { useCreateDatabase } from '../CreateDatabase/useCreateDatabase'; +import { CreateDatabaseDialog } from '../CreateDatabase/CreateDatabaseDialog'; + import { AwsRegionSelector } from './AwsRegionSelector'; -import { DatabaseList } from './DatabaseList'; +import { DatabaseList } from './RdsDatabaseList'; type TableData = { - items: ListAwsDatabaseResponse['databases']; + items: ListAwsRdsDatabaseResponse['databases']; fetchStatus: FetchStatus; startKey?: string; currRegion?: Regions; @@ -47,18 +52,26 @@ const emptyTableData: TableData = { startKey: '', }; -// TODO(lisa): need to add a new event for this, or can -// we re-use "create database event"? export function EnrollRdsDatabase() { - const { nextStep, agentMeta } = useDiscover(); - const { attempt, setAttempt } = useAttempt(''); + const { + createdDb, + pollTimeout, + registerDatabase, + attempt: registerAttempt, + clearAttempt: clearRegisterAttempt, + nextStep, + } = useCreateDatabase(); + + const { agentMeta, resourceSpec } = useDiscover(); + const { attempt: fetchDbAttempt, setAttempt: setFetchDbAttempt } = + useAttempt(''); const [tableData, setTableData] = useState({ items: [], startKey: '', fetchStatus: 'disabled', }); - const [selectedDb, setSelectedDb] = useState(); + const [selectedDb, setSelectedDb] = useState(); function fetchDatabasesWithNewRegion(region: Regions) { // Clear table when fetching with new region. @@ -70,19 +83,22 @@ export function EnrollRdsDatabase() { } function fetchDatabases(data: TableData) { - const integrationName = (agentMeta as DbMeta).awsIntegrationName; + const integrationName = (agentMeta as DbMeta).integrationName; setTableData({ ...data, fetchStatus: 'loading' }); - setAttempt({ status: 'processing' }); + setFetchDbAttempt({ status: 'processing' }); - // TODO(lisa): re-visit after backend implementation is final integrationService - .fetchAwsDatabases(integrationName, { - region: data.currRegion, - nextToken: data.startKey, - }) + .fetchAwsRdsDatabases( + integrationName, + getRdsEngineIdentifier(resourceSpec.dbMeta?.engine), + { + region: data.currRegion, + nextToken: data.startKey, + } + ) .then(resp => { - setAttempt({ status: 'success' }); + setFetchDbAttempt({ status: 'success' }); setTableData({ currRegion: data.currRegion, startKey: resp.nextToken, @@ -92,20 +108,16 @@ export function EnrollRdsDatabase() { }); }) .catch((err: Error) => { - setAttempt({ status: 'failed', statusText: err.message }); + setFetchDbAttempt({ status: 'failed', statusText: err.message }); setTableData(data); // fallback to previous data }); } - function handleOnProceed() { - // TODO(lisa): - // Update agent meta with the selected RDS database. - nextStep(); - } - function clear() { - if (attempt.status === 'failed') { - setAttempt({ status: '' }); + clearRegisterAttempt(); + + if (fetchDbAttempt.status === 'failed') { + setFetchDbAttempt({ status: '' }); } if (tableData.items.length > 0) { setTableData(emptyTableData); @@ -115,18 +127,40 @@ export function EnrollRdsDatabase() { } } + function handleOnProceed() { + // Append `teleport_` to the RDS db name to + // lower the chance of duplicate db name. + + registerDatabase( + { + name: selectedDb.name, + protocol: selectedDb.engine, + uri: selectedDb.uri, + labels: selectedDb.labels, + awsRds: { + accountId: selectedDb.accountId, + resourceId: selectedDb.resourceId, + }, + }, + // 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 + ); + } + return (
Enroll a RDS Database
- {attempt.status === 'failed' && ( - {attempt.statusText} + {fetchDbAttempt.status === 'failed' && ( + {fetchDbAttempt.statusText} )} 0 + fetchDbAttempt.status === 'processing' || tableData.items.length > 0 } /> + {registerAttempt.status !== '' && ( + + )}
); } + +function getRdsEngineIdentifier(engine: DatabaseEngine): RdsEngineIdentifier { + switch (engine) { + case DatabaseEngine.MySql: + return 'mysql'; + case DatabaseEngine.Postgres: + return 'postgres'; + case DatabaseEngine.AuroraMysql: + return 'aurora-mysql'; + case DatabaseEngine.AuroraPostgres: + return 'aurora-postgres'; + } +} diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx new file mode 100644 index 0000000000000..ada2c8aa2a556 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/RdsDatabaseList.tsx @@ -0,0 +1,213 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Flex, Box, Label as Pill } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { FetchStatus } from 'design/DataTable/types'; + +import { + AwsRdsDatabase, + ListAwsRdsDatabaseResponse, +} from 'teleport/services/integrations'; +import { Label } from 'teleport/types'; + +type Props = { + items: ListAwsRdsDatabaseResponse['databases']; + fetchStatus: FetchStatus; + fetchNextPage(): void; + onSelectDatabase(item: AwsRdsDatabase): void; + selectedDatabase?: AwsRdsDatabase; +}; + +export const DatabaseList = ({ + items = [], + fetchStatus = '', + fetchNextPage, + onSelectDatabase, + selectedDatabase, +}: Props) => { + return ( +
{ + const isChecked = + item.name === selectedDatabase?.name && + item.engine === selectedDatabase?.engine; + return ( + + ); + }, + }, + { + key: 'name', + headerText: 'Name', + render: ({ name }) => {name}, + }, + { + key: 'engine', + headerText: 'Engine', + render: ({ engine }) => {engine}, + }, + { + key: 'labels', + headerText: 'Labels', + render: ({ labels }) => , + }, + { + key: 'status', + headerText: 'Status', + render: item => , + }, + ]} + emptyText="No Results" + customSearchMatchers={[labelMatcher]} + pagination={{ pageSize: 10 }} + fetching={{ onFetchMore: fetchNextPage, fetchStatus }} + isSearchable + /> + ); +}; + +const StatusCell = ({ item }: { item: AwsRdsDatabase }) => { + const status = getStatus(item); + + return ( + + + + {item.status} + + + ); +}; + +function RadioCell({ + item, + isChecked, + onChange, +}: { + item: AwsRdsDatabase; + isChecked: boolean; + onChange(selectedItem: AwsRdsDatabase): void; +}) { + return ( + + + props.theme.space[2]}px 0 0; + accent-color: ${props => props.theme.colors.brand.accent}; + cursor: pointer; + `} + type="radio" + name={item.name} + checked={isChecked} + onChange={() => onChange(item)} + value={item.name} + /> + + + ); +} + +enum Status { + Success, + Warning, + Error, +} + +function getStatus(item: AwsRdsDatabase) { + switch (item.status) { + case 'Available': + return Status.Success; + + case 'Failed': + case 'Deleting': + return Status.Error; + } +} + +// TODO(lisa): copy from IntegrationList.tsx +// move to common file for both files. +const StatusLight = styled(Box)` + border-radius: 50%; + margin-right: 6px; + width: 8px; + height: 8px; + background-color: ${({ status, theme }) => { + if (status === Status.Success) { + return theme.colors.success; + } + if (status === Status.Error) { + return theme.colors.error.main; + } + if (status === Status.Warning) { + return theme.colors.warning; + } + return theme.colors.grey[300]; // Unknown + }}; +`; + +const LabelCell = ({ labels }: { labels: Label[] }) => { + const $labels = labels.map((label, index) => { + const labelText = `${label.name}: ${label.value}`; + + return ( + + {labelText} + + ); + }); + + return ( + + {$labels} + + ); +}; + +// labelMatcher allows user to client search by labels in the format +// 1) `key: value` or +// 2) `key:value` or +// 3) `key` or `value` +function labelMatcher( + targetValue: any, + searchValue: string, + propName: keyof AwsRdsDatabase & string +) { + if (propName === 'labels') { + return targetValue.some((label: Label) => { + const convertedKey = label.name.toLocaleUpperCase(); + const convertedVal = label.value.toLocaleUpperCase(); + const formattedWords = [ + `${convertedKey}:${convertedVal}`, + `${convertedKey}: ${convertedVal}`, + ]; + return formattedWords.some(w => w.includes(searchValue)); + }); + } +} diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx index f7b596e750e2c..cd1887a32c439 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx @@ -200,7 +200,10 @@ function DbEngineInstructions({ }) { switch (dbLocation) { case DatabaseLocation.Aws: - if (dbEngine === DatabaseEngine.Postgres) { + if ( + dbEngine === DatabaseEngine.Postgres || + dbEngine === DatabaseEngine.AuroraPostgres + ) { return ( @@ -227,7 +230,10 @@ function DbEngineInstructions({ ); } - if (dbEngine === DatabaseEngine.MySql) { + if ( + dbEngine === DatabaseEngine.MySql || + dbEngine === DatabaseEngine.AuroraMysql + ) { return ( diff --git a/web/packages/teleport/src/Discover/SelectResource/types.ts b/web/packages/teleport/src/Discover/SelectResource/types.ts index 8f5aaf6b5e757..ba1f284ff7f62 100644 --- a/web/packages/teleport/src/Discover/SelectResource/types.ts +++ b/web/packages/teleport/src/Discover/SelectResource/types.ts @@ -31,7 +31,9 @@ export enum DatabaseLocation { // DatabaseEngine represents the db "protocol". export enum DatabaseEngine { Postgres, + AuroraPostgres, MySql, + AuroraMysql, MongoDb, Redis, CoackroachDb, diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index 81467c6b9bc6c..875560ddf724a 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -40,7 +40,7 @@ import type { Database } from 'teleport/services/databases'; import type { AgentLabel } from 'teleport/services/agents'; import type { ResourceSpec } from './SelectResource'; -interface DiscoverContextState { +export interface DiscoverContextState { agentMeta: AgentMeta; currentStep: number; nextStep: (count?: number) => void; @@ -68,9 +68,16 @@ type CustomEventInput = { autoDiscoverResourcesCount?: number; }; +type DiscoverProviderProps = { + // mockCtx used for testing purposes. + mockCtx?: DiscoverContextState; +}; + const discoverContext = React.createContext(null); -export function DiscoverProvider(props: React.PropsWithChildren) { +export function DiscoverProvider( + props: React.PropsWithChildren +) { const history = useHistory(); const [currentStep, setCurrentStep] = useState(0); @@ -304,7 +311,7 @@ export function DiscoverProvider(props: React.PropsWithChildren) { }; return ( - + {props.children} ); @@ -336,7 +343,7 @@ export type NodeMeta = BaseMeta & { // that needs to be preserved throughout the flow. export type DbMeta = BaseMeta & { db: Database; - awsIntegrationName?: string; + integrationName?: string; }; // KubeMeta describes the fields for a kube resource diff --git a/web/packages/teleport/src/Discover/yamlTemplates/index.ts b/web/packages/teleport/src/Discover/yamlTemplates/index.ts index 27f008c740451..1f65acf5795cd 100644 --- a/web/packages/teleport/src/Discover/yamlTemplates/index.ts +++ b/web/packages/teleport/src/Discover/yamlTemplates/index.ts @@ -22,7 +22,7 @@ import kubeAccessRO from './kubeAccessRO.yaml?raw'; import dbAccessRW from './dbAccessRW.yaml?raw'; import dbAccessRO from './dbAccessRO.yaml?raw'; import dbCU from './dbCU.yaml?raw'; -import integrationRWE from './integrationRWE.yaml?raw'; +import integrationRWEAndDbCU from './integrationRWEAndDbCU.yaml?raw'; export { nodeAccessRO, @@ -33,5 +33,5 @@ export { dbAccessRO, dbAccessRW, dbCU, - integrationRWE, + integrationRWEAndDbCU, }; diff --git a/web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndDbCU.yaml similarity index 64% rename from web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml rename to web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndDbCU.yaml index 2868a0b959e98..61e643f3ae6cd 100644 --- a/web/packages/teleport/src/Discover/yamlTemplates/integrationRWE.yaml +++ b/web/packages/teleport/src/Discover/yamlTemplates/integrationRWEAndDbCU.yaml @@ -8,3 +8,8 @@ spec: - list - create - use + - resources: + - db + verbs: + - create + - update diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index de0b35e2f0f4c..0d6cf8f46da9b 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -215,6 +215,9 @@ const cfg = { integrationExecutePath: '/v1/webapi/sites/:clusterId/integrations/:name/action/:action', + awsRdsDbListPath: + '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/databases', + userGroupsListPath: '/v1/webapi/sites/:clusterId/user-groups?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', }, @@ -621,6 +624,7 @@ const cfg = { }, getIntegrationExecuteUrl(params: UrlIntegrationExecuteRequestParams) { + // Currently you can only create integrations at the root cluster. const clusterId = cfg.proxyCluster; return generatePath(cfg.api.integrationExecutePath, { @@ -629,6 +633,15 @@ const cfg = { }); }, + getAwsRdsDbListUrl(integrationName: string) { + const clusterId = cfg.proxyCluster; + + return generatePath(cfg.api.awsRdsDbListPath, { + clusterId, + name: integrationName, + }); + }, + getUIConfig() { return cfg.ui; }, @@ -722,7 +735,7 @@ export interface UrlIntegrationExecuteRequestParams { name: string; // action is the expected backend string value // used to describe what to use the integration for. - action: 'list_databases'; + action: 'aws-oidc/list_databases'; } export default cfg; diff --git a/web/packages/teleport/src/services/databases/types.ts b/web/packages/teleport/src/services/databases/types.ts index 08c39b384663c..c7e6e8490c299 100644 --- a/web/packages/teleport/src/services/databases/types.ts +++ b/web/packages/teleport/src/services/databases/types.ts @@ -18,6 +18,8 @@ import { DbProtocol } from 'shared/services/databases'; import { AgentLabel } from 'teleport/services/agents'; +import { RdsEngine } from '../integrations'; + export interface Database { name: string; description: string; @@ -44,7 +46,7 @@ export type UpdateDatabaseRequest = Omit< export type CreateDatabaseRequest = { name: string; - protocol: DbProtocol; + protocol: DbProtocol | RdsEngine; uri: string; labels?: AgentLabel[]; awsRds?: AwsRds; diff --git a/web/packages/teleport/src/services/integrations/integrations.test.ts b/web/packages/teleport/src/services/integrations/integrations.test.ts new file mode 100644 index 0000000000000..0c665e745f6d1 --- /dev/null +++ b/web/packages/teleport/src/services/integrations/integrations.test.ts @@ -0,0 +1,227 @@ +/** + * 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 { integrationService } from './integrations'; +import { IntegrationStatusCode } from './types'; + +test('fetchIntegration() response (a integration)', async () => { + // test a valid response + jest.spyOn(api, 'get').mockResolvedValue(awsOidcIntegration); + + let response = await integrationService.fetchIntegration('integration-name'); + expect(api.get).toHaveBeenCalledWith( + cfg.getIntegrationsUrl('integration-name') + ); + expect(response).toEqual({ + kind: 'aws-oidc', + name: 'aws-oidc-integration', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }); + + // test null response + jest.spyOn(api, 'get').mockResolvedValue(null); + + response = await integrationService.fetchIntegration('integration-name'); + expect(response).toEqual({ + resourceType: 'integration', + statusCode: IntegrationStatusCode.Running, + kind: undefined, + name: undefined, + spec: { + roleArn: undefined, + }, + }); +}); + +test('fetchIntegrations() response (list)', async () => { + // test a valid response + jest.spyOn(api, 'get').mockResolvedValue({ + items: [awsOidcIntegration, nonAwsOidcIntegration], + nextKey: 'some-key', + }); + + let response = await integrationService.fetchIntegrations(); + expect(api.get).toHaveBeenCalledWith(cfg.getIntegrationsUrl()); + expect(response).toEqual({ + nextKey: 'some-key', + items: [ + { + kind: 'aws-oidc', + name: 'aws-oidc-integration', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }, + { + kind: 'abc', + name: 'non-aws-oidc-integration', + resourceType: 'integration', + spec: { + roleArn: undefined, + }, + statusCode: IntegrationStatusCode.Running, + }, + ], + }); + + // test null response + jest.spyOn(api, 'get').mockResolvedValue(null); + + response = await integrationService.fetchIntegrations(); + expect(response).toEqual({ + items: [], + nextKey: undefined, + }); +}); + +test('fetchAwsDatabases response', async () => { + // test a valid response + jest + .spyOn(api, 'post') + .mockResolvedValue({ databases: mockAwsDbs, nextToken: 'next-token' }); + + let response = await integrationService.fetchAwsRdsDatabases( + 'integration-name', + 'mysql', + { region: 'us-east-1', nextToken: 'next-token' } + ); + + expect(response).toEqual({ + databases: [ + { + engine: 'postgres', + name: 'rds-1', + uri: 'endpoint-1', + status: 'Available', + labels: [{ name: 'env', value: 'prod' }], + accountId: 'account-id-1', + resourceId: 'resource-id-1', + }, + { + engine: 'mysql', + name: 'rds-2', + uri: 'endpoint-2', + status: 'Available', + labels: [], + accountId: undefined, + resourceId: undefined, + }, + { + engine: 'mysql', + name: 'rds-3', + uri: 'endpoint-3', + status: 'Available', + labels: [], + accountId: undefined, + resourceId: undefined, + }, + ], + nextToken: 'next-token', + }); + + // test null response + jest.spyOn(api, 'post').mockResolvedValue(null); + + response = await integrationService.fetchAwsRdsDatabases( + 'integration-name', + 'mysql', + {} as any + ); + expect(response).toEqual({ + databases: [], + nextToken: undefined, + }); +}); + +describe('fetchAwsDatabases() request body formatting', () => { + test.each` + protocol | expectedEngines | expectedRdsType + ${'mysql'} | ${['mysql', 'mariadb']} | ${'instance'} + ${'postgres'} | ${['postgres']} | ${'instance'} + ${'aurora-mysql'} | ${['aurora', 'aurora-mysql']} | ${'cluster'} + ${'aurora-postgres'} | ${['aurora-postgresql']} | ${'cluster'} + `( + 'format protocol $protocol', + async ({ protocol, expectedEngines, expectedRdsType }) => { + jest.spyOn(api, 'post').mockResolvedValue({ databases: [] }); // not testing response here. + + await integrationService.fetchAwsRdsDatabases(protocol, protocol, { + region: 'us-east-1', + nextToken: 'next-token', + }); + + expect(api.post).toHaveBeenCalledWith( + `/v1/webapi/sites/localhost/integrations/aws-oidc/${protocol}/databases`, + { + rdsType: expectedRdsType, + engines: expectedEngines, + region: 'us-east-1', + nextToken: 'next-token', + } + ); + } + ); +}); + +const nonAwsOidcIntegration = { + name: 'non-aws-oidc-integration', + subKind: 'abc', +}; +const awsOidcIntegration = { + name: 'aws-oidc-integration', + subKind: 'aws-oidc', + awsoidc: { roleArn: 'arn-123' }, +}; + +const mockAwsDbs = [ + { + protocol: 'postgres', + name: 'rds-1', + uri: 'endpoint-1', + status: 'Available', + labels: [{ name: 'env', value: 'prod' }], + aws: { + account_id: 'account-id-1', + rds: { + resource_id: 'resource-id-1', + }, + }, + }, + // Test with empty aws fields. + { + protocol: 'mysql', + name: 'rds-2', + uri: 'endpoint-2', + status: 'Available', + aws: {}, + }, + // Test without aws field. + { + protocol: 'mysql', + name: 'rds-3', + uri: 'endpoint-3', + status: 'Available', + }, +]; diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 88d3d63f6ff22..5f3e4bb29b643 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -22,9 +22,10 @@ import { IntegrationCreateRequest, IntegrationStatusCode, IntegrationListResponse, - IntegrationExecuteRequest, - AwsDatabase, - ListAwsDatabaseResponse, + AwsOidcListDatabasesRequest, + AwsRdsDatabase, + ListAwsRdsDatabaseResponse, + RdsEngineIdentifier, } from './types'; export const integrationService = { @@ -54,20 +55,50 @@ export const integrationService = { return api.delete(cfg.getIntegrationsUrl(name)); }, - fetchAwsDatabases( + fetchAwsRdsDatabases( integrationName, - req: IntegrationExecuteRequest - ): Promise { + rdsEngineIdentifier: RdsEngineIdentifier, + req: { + region: AwsOidcListDatabasesRequest['region']; + nextToken?: AwsOidcListDatabasesRequest['nextToken']; + } + ): Promise { + let body: AwsOidcListDatabasesRequest; + switch (rdsEngineIdentifier) { + case 'mysql': + body = { + ...req, + rdsType: 'instance', + engines: ['mysql', 'mariadb'], + }; + break; + case 'postgres': + body = { + ...req, + rdsType: 'instance', + engines: ['postgres'], + }; + break; + case 'aurora-mysql': + body = { + ...req, + rdsType: 'cluster', + engines: ['aurora', 'aurora-mysql'], + }; + break; + case 'aurora-postgres': + body = { + ...req, + rdsType: 'cluster', + engines: ['aurora-postgresql'], + }; + break; + } + return api - .post( - cfg.getIntegrationExecuteUrl({ - name: integrationName, - action: 'list_databases', - }), - req - ) + .post(cfg.getAwsRdsDbListUrl(integrationName), body) .then(json => { - const dbs = json?.databases; + const dbs = json?.databases ?? []; return { databases: dbs.map(makeAwsDatabase), nextToken: json?.nextToken, @@ -100,12 +131,17 @@ function makeIntegration(json: any): Integration { }; } -export function makeAwsDatabase(json: any): AwsDatabase { +export function makeAwsDatabase(json: any): AwsRdsDatabase { json = json ?? {}; - const { engine, name, endpoint } = json; + const { aws, name, uri, status, labels, protocol } = json; + return { - engine, + engine: protocol, name, - endpoint, + uri, + status, + labels: labels ?? [], + resourceId: aws?.rds?.resource_id, + accountId: aws?.account_id, }; } diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 785b0564044d7..2657af5b98ea0 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Label } from 'teleport/types'; + /** * type Integration v. type Plugin: * @@ -137,23 +139,64 @@ export const awsRegionMap = { }; export type Regions = keyof typeof awsRegionMap; -export type IntegrationExecuteRequest = { + +// RdsEngine are the expected backend string values, +// used when requesting lists of rds databases of the +// specified engine. +export type RdsEngine = + | 'aurora' // (for MySQL 5.6-compatible Aurora) + | 'aurora-mysql' // (for MySQL 5.7-compatible and MySQL 8.0-compatible Aurora) + | 'aurora-postgresql' + | 'mariadb' + | 'mysql' + | 'postgres'; + +// RdsEngineIdentifier are the name of engines +// used to determine the grouping of similar RdsEngines. +// eg: if `aurora-mysql` then the grouping of RdsEngines +// is 'aurora, aurora-mysql`, they are both mysql but +// refer to different versions. This type is used solely +// for frontend. +export type RdsEngineIdentifier = + | 'mysql' + | 'postgres' + | 'aurora-mysql' + | 'aurora-postgres'; + +export type AwsOidcListDatabasesRequest = { + // engines is used as a filter to get a list of specified engines only. + engines: RdsEngine[]; region: Regions; // nextToken is the start key for the next page nextToken?: string; + // rdsType describes the type of RDS dbs to request. + // `cluster` is used for requesting aurora related + // engines, and `instance` for rest of engines. + rdsType: 'instance' | 'cluster'; }; -export type AwsDatabase = { - // engine of the database. Eg, sqlserver-ex - engine: string; +export type AwsRdsDatabase = { + // engine of the database. eg. aurora-mysql + engine: RdsEngine; // name is the the Database's name. name: string; - // endpoint contains the URI for connecting to this Database - endpoint: string; + // uri contains the endpoint with port for connecting to this Database. + uri: string; + // resourceId is the AWS Region-unique, immutable identifier for the DB. + resourceId: string; + // accountId is the AWS account id. + accountId: string; + // labels contains this Instance tags. + labels: Label[]; + // status contains this Instance status. + // There is a lot of status states available so only a select few were + // hard defined to use to determine the status color. + // https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/accessing-monitoring.html + status: 'Available' | 'Failed' | 'Deleting'; }; -export type ListAwsDatabaseResponse = { - databases: AwsDatabase[]; +export type ListAwsRdsDatabaseResponse = { + databases: AwsRdsDatabase[]; // nextToken is the start key for the next page. // Empty value means last page. nextToken?: string; From 231d1184409316d56c69301a87e38d6b724a83a0 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Tue, 25 Apr 2023 12:57:34 -0700 Subject: [PATCH 3/6] WebDiscover: Hookup AWS RDS Flow (#24873) --- .../ConnectAwsAccount/ConnectAwsAccount.tsx | 43 ++- .../CreateDatabase/CreateDatabaseDialog.tsx | 22 +- .../CreateDatabase/useCreateDatabase.test.tsx | 48 ++-- .../CreateDatabase/useCreateDatabase.ts | 17 +- .../DownloadScript/DownloadScript.story.tsx | 5 +- .../DownloadScript/DownloadScript.tsx | 154 ++++++----- .../EnrollRdsDatabase/AwsRegionSelector.tsx | 50 +++- .../EnrollRdsDatabase.story.tsx | 26 +- .../EnrollRdsDatabase/EnrollRdsDatabase.tsx | 16 +- .../EnrollRdsDatabase/RdsDatabaseList.tsx | 6 +- .../Database/SetupAccess/SetupAccess.tsx | 20 +- .../teleport/src/Discover/Database/index.tsx | 30 ++- .../teleport/src/Discover/Database/util.ts | 121 +++------ .../src/Discover/Database/utils.test.ts | 113 ++++++++ .../teleport/src/Discover/Discover.tsx | 2 +- .../src/Discover/Navigation/StepItem.tsx | 5 +- .../SelectResource/SelectResource.tsx | 4 +- .../SelectResource.story.test.tsx.snap | 250 ++++++++++++++++++ .../src/Discover/SelectResource/databases.tsx | 77 ++++-- .../src/Discover/SelectResource/icons.tsx | 2 + .../src/Discover/SelectResource/resources.tsx | 17 +- .../src/Discover/SelectResource/types.ts | 3 +- .../Server/SetupAccess/SetupAccess.tsx | 2 +- web/packages/teleport/src/Discover/flow.tsx | 4 +- .../src/Discover/useDiscover.test.tsx | 2 + .../teleport/src/Discover/useDiscover.tsx | 137 ++++++++-- .../instructions/FifthStageInstructions.tsx | 5 +- .../instructions/Instructions.story.tsx | 16 +- .../instructions/SecondStageInstructions.tsx | 15 +- .../instructions/SeventhStageInstructions.tsx | 18 +- .../instructions/SixthStageInstructions.tsx | 7 +- .../instructions/ThirdStageInstructions.tsx | 3 +- .../integrations/integrations.test.ts | 8 +- .../src/services/integrations/integrations.ts | 4 +- .../src/services/integrations/types.ts | 2 +- .../teleport/src/services/user/makeAcl.ts | 5 +- 36 files changed, 893 insertions(+), 366 deletions(-) create mode 100644 web/packages/teleport/src/Discover/Database/utils.test.ts diff --git a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx index b622259338732..21873c62500fb 100644 --- a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx +++ b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx @@ -18,14 +18,13 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { Box, - ButtonLink, + ButtonText, Text, ButtonPrimary, Indicator, Alert, Flex, } from 'design'; -import theme from 'design/theme'; import FieldSelect from 'shared/components/FieldSelect'; import useAttempt from 'shared/hooks/useAttemptNext'; import { Option } from 'shared/components/Select'; @@ -43,12 +42,23 @@ import useTeleport from 'teleport/useTeleport'; import { ActionButtons, HeaderSubtitle, HeaderWithBackBtn } from '../../Shared'; -import { DbMeta, useDiscover } from '../../useDiscover'; +import { + DbMeta, + DiscoverUrlLocationState, + useDiscover, +} from '../../useDiscover'; export function ConnectAwsAccount() { const { storeUser } = useTeleport(); - const { prevStep, nextStep, agentMeta, updateAgentMeta, eventState } = - useDiscover(); + const { + prevStep, + nextStep, + agentMeta, + updateAgentMeta, + eventState, + resourceSpec, + currentStep, + } = useDiscover(); const integrationAccess = storeUser.getIntegrationsAccess(); const databaseAccess = storeUser.getDatabaseAccess(); @@ -141,14 +151,24 @@ export function ConnectAwsAccount() { integrationName: selectedAwsIntegration.value, }); - // TODO(lisa): Need to add a new event to emit for this screen. nextStep(); } const hasAwsIntegrations = awsIntegrations.length > 0; + + // When a user clicks to create a new AWS integration, we + // define location state to preserve all the states required + // to resume from this step when the user comes back to discover route + // after successfully finishing enrolling integration. const locationState = { pathname: cfg.getIntegrationEnrollRoute(IntegrationKind.AwsOidc), - state: { discoverEventId: eventState?.id }, + state: { + discover: { + eventState, + resourceSpec, + currentStep, + }, + } as DiscoverUrlLocationState, }; return ( @@ -175,14 +195,9 @@ export function ConnectAwsAccount() { options={awsIntegrations} /> - + Or click here to set up a different AWS account - + ) : ( - - Database Register Failed - - Error: {attempt.statusText} + Register Failed: {attempt.statusText} @@ -70,11 +67,9 @@ export function CreateDatabaseDialog({ } else if (attempt.status === 'processing') { content = ( <> - - Registering Database - - + + + Next + ); } else { // success content = ( <> - - Successfully Registered Database - Database "{dbName}" successfully registered - + Next @@ -114,6 +109,9 @@ export function CreateDatabaseDialog({ mb={0} textAlign="center" > + + Database Register + {content} diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx index 080a3fabe0917..97e1b1c05ef31 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx @@ -87,7 +87,19 @@ const services = [ const testCases = [ { - name: 'match in multiple services', + name: 'match with a service', + newLabels: dbLabels, + services: [ + { + name: 'svc4', + matcherLabels: { env: ['prod'] }, + awsIdentity: emptyAwsIdentity, + }, + ], + expectedMatch: 'svc4', + }, + { + name: 'match among multple service', newLabels: dbLabels, services, expectedMatch: 'svc2', @@ -96,26 +108,14 @@ const testCases = [ name: 'no match despite matching all labels when a svc has a non-matching label', newLabels: dbLabels, services: [ - { - name: 'svc1', - matcherLabels: { os: ['windows', 'mac'], env: ['staging'] }, - awsIdentity: emptyAwsIdentity, - }, { name: 'svc2', matcherLabels: { os: ['windows', 'mac', 'linux'], - tag: ['v11.0.0'], - env: ['staging', 'prod'], fruit: ['apple', '*'], // the non-matching label }, awsIdentity: emptyAwsIdentity, }, - { - name: 'svc3', - matcherLabels: { env: ['prod'], fruit: ['orange'] }, - awsIdentity: emptyAwsIdentity, - }, ], expectedMatch: undefined, }, @@ -161,7 +161,7 @@ const testCases = [ name: 'svc2', matcherLabels: { os: ['linux', 'mac'], - '*': ['prod', 'apple', 'v11.0.0'], + '*': ['prod', 'apple'], }, awsIdentity: emptyAwsIdentity, }, @@ -174,7 +174,7 @@ const testCases = [ services: [ { name: 'svc1', - matcherLabels: { '*': ['windows', 'mac'] }, + matcherLabels: { '*': ['windows'] }, awsIdentity: emptyAwsIdentity, }, ], @@ -204,7 +204,7 @@ const testCases = [ name: 'svc1', matcherLabels: { fruit: ['*'], - os: ['mac'], + os: ['windows'], }, awsIdentity: emptyAwsIdentity, }, @@ -239,9 +239,11 @@ const testCases = [ }, ]; -test.each(testCases)('$name', ({ newLabels, services, expectedMatch }) => { - const foundSvc = findActiveDatabaseSvc(newLabels, services); - expect(foundSvc?.name).toEqual(expectedMatch); +describe('findActiveDatabaseSvc()', () => { + test.each(testCases)('$name', ({ newLabels, services, expectedMatch }) => { + const foundSvc = findActiveDatabaseSvc(newLabels, services); + expect(foundSvc?.name).toEqual(expectedMatch); + }); }); const newDatabaseReq: CreateDatabaseRequest = { @@ -284,9 +286,9 @@ describe('registering new databases, mainly error checking', () => { jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(null as never); // return value does not matter but required by ts - jest - .spyOn(teleCtx.databaseService, 'fetchDatabases') - .mockResolvedValue({ agents: [{ name: 'new-db' } as any] }); + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [{ name: 'new-db', labels: dbLabels } as any], + }); jest .spyOn(teleCtx.databaseService, 'createDatabase') .mockResolvedValue(null); // ret val not used @@ -339,7 +341,7 @@ describe('registering new databases, mainly error checking', () => { expect(discoverCtx.updateAgentMeta).toHaveBeenCalledWith({ resourceName: 'db-name', agentMatcherLabels: dbLabels, - db: { name: 'new-db' }, + db: { name: 'new-db', labels: dbLabels }, }); // Test the dynamic definition of nextStep is called with a number diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts index f71be652de845..afbcc75d995d8 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts @@ -22,7 +22,7 @@ import { useDiscover } from 'teleport/Discover/useDiscover'; import { usePoll } from 'teleport/Discover/Shared/usePoll'; import { compareByString } from 'teleport/lib/util'; -import { matchLabels, makeLabelMaps } from '../util'; +import { matchLabels } from '../util'; import type { CreateDatabaseRequest, @@ -111,7 +111,7 @@ export function useCreateDatabase() { updateAgentMeta({ ...(agentMeta as DbMeta), resourceName: createdDb.name, - agentMatcherLabels: createdDb.labels, + agentMatcherLabels: dbPollingResult.labels, db: dbPollingResult, }); @@ -257,21 +257,10 @@ export function findActiveDatabaseSvc( return null; } - // Create maps for easy lookup and matching. - const { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap } = - makeLabelMaps(newDbLabels); - - const hasLabelsToMatch = newDbLabels.length > 0; for (let i = 0; i < dbServices.length; i++) { // Loop through the current service label keys and its value set. const currService = dbServices[i]; - const match = matchLabels({ - hasLabelsToMatch, - labelKeysToMatchMap, - labelValsToMatchMap, - labelToMatchSeenMap, - matcherLabels: currService.matcherLabels, - }); + const match = matchLabels(newDbLabels, currService.matcherLabels); if (match) { return currService; diff --git a/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx index 8758cb2cf20cd..62dc97e359394 100644 --- a/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx +++ b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx @@ -55,7 +55,10 @@ export const InitWithLabels = () => { {...props} agentMeta={{ ...props.agentMeta, - agentMatcherLabels: [{ name: 'env', value: 'prod' }], + agentMatcherLabels: [ + { name: 'env', value: 'staging' }, + { name: 'os', value: 'windows' }, + ], }} /> diff --git a/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx index af0ca5a685456..e91adf8ded7b0 100644 --- a/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx +++ b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { Suspense, useState } from 'react'; -import { Box, ButtonSecondary, Text } from 'design'; +import React, { Suspense, useState, useEffect } from 'react'; +import { Box, ButtonSecondary, Label as Pill, Text } from 'design'; import * as Icons from 'design/Icon'; -import Validation, { useRule, Validator } from 'shared/components/Validation'; +import Validation, { Validator } from 'shared/components/Validation'; import { CatchError } from 'teleport/components/CatchError'; import { @@ -50,7 +50,7 @@ import { TextIcon, useShowHint, } from '../../Shared'; -import { makeLabelMaps, matchLabels } from '../util'; +import { matchLabels } from '../util'; import type { AgentStepProps } from '../../types'; @@ -58,12 +58,11 @@ export default function Container(props: AgentStepProps) { const hasDbLabels = props.agentMeta?.agentMatcherLabels?.length; const dbLabels = hasDbLabels ? props.agentMeta.agentMatcherLabels : []; const [labels, setLabels] = useState( - hasDbLabels - ? dbLabels - : // If user did not define any labels from previous step (create db) - // then the only way the agent will know how to pick up the - // new db is through asteriks. - [{ name: '*', value: '*', isFixed: true }] + // TODO(lisa): we will always be defaulting to asterisks, so + // instead of defining the default here, define it inside + // the LabelsCreator (component responsible for rendering + // label input boxes) + [{ name: '*', value: '*', isFixed: dbLabels.length === 0 }] ); const [showScript, setShowScript] = useState(false); @@ -99,17 +98,7 @@ export default function Container(props: AgentStepProps) { } > {!showScript && ( - - - - setShowScript(true)} - > - Generate Command - - null} disableProceed={true} /> - + )} {showScript && ( { return ( <> -
Optionally Deploy a Database Service
+
Deploy a Database Service
- This step is optional if you already have a Teleport Database Service - running. -
On the host where you will run the Teleport Database Service, execute the generated command that will install and start Teleport with the appropriate configuration. @@ -248,32 +234,65 @@ export const Labels = ({ setLabels, disableBtns = false, dbLabels, + showLabelMatchErr = false, }: { labels: AgentLabel[]; setLabels(l: AgentLabel[]): void; disableBtns?: boolean; dbLabels: AgentLabel[]; + showLabelMatchErr?: boolean; }) => { - const { valid, message } = useRule(requireMatchingLabels(dbLabels, labels)); - const hasError = !valid; - + const hasDbLabels = dbLabels.length > 0; return ( - Labels - - At least one label is required to help this service identify your - database. + Optionally Define Matcher Labels + + {!hasDbLabels && ( + <> + Since no labels were defined for the registered database from the + previous step, the matcher labels are defaulted to wildcards which + will allow this database service to match any database. + + )} + {hasDbLabels && ( + <> + Default wildcards label allows this database service to match any + database. +
+ You can define your own labels that this database service will use + to identify your registered database. The labels you define must + match the labels that were defined for the registered database (from + previous step): + + )}
+ {hasDbLabels && ( + + {dbLabels.map((label, index) => { + const labelText = `${label.name}: ${label.value}`; + return ( + + {labelText} + + ); + })} + + )} - - {hasError && ( - - - {message} + + {showLabelMatchErr && ( + + + The matcher labels must be able to match with the labels defined for + the registered database. Use wildcards to match with any labels. )} @@ -285,18 +304,6 @@ function createBashCommand(tokenId: string) { return `sudo bash -c "$(curl -fsSL ${cfg.getDbScriptUrl(tokenId)})"`; } -const requireMatchingLabels = - (dbLabels: AgentLabel[], agentLabels: AgentLabel[]) => () => { - if (!hasMatchingLabels(dbLabels, agentLabels)) { - return { - valid: false, - message: `Labels must match with the labels defined for the database resource. \ - To match any key, and/or any value, asteriks can be used.`, - }; - } - return { valid: true }; - }; - // hasMatchingLabels will go through each 'agentLabels' and find matches from // 'dbLabels'. The 'agentLabels' must have same amount of matching labels // with 'dbLabels' either with asteriks (match all) or by exact match. @@ -322,15 +329,42 @@ export function hasMatchingLabels( matcherLabels[l.name] = [...matcherLabels[l.name], l.value]; }); - // Create maps for easy lookup and matching. - const { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap } = - makeLabelMaps(dbLabels); + return matchLabels(dbLabels, matcherLabels); +} - return matchLabels({ - hasLabelsToMatch: dbLabels.length > 0, - labelKeysToMatchMap, - labelValsToMatchMap, - labelToMatchSeenMap, - matcherLabels, - }); +function LoadedView({ labels, setLabels, dbLabels, setShowScript }) { + const [showLabelMatchErr, setShowLabelMatchErr] = useState(true); + + useEffect(() => { + // Turn off error once user changes labels. + if (showLabelMatchErr) { + setShowLabelMatchErr(false); + } + }, [labels]); + + function handleGenerateCommand() { + if (!hasMatchingLabels(dbLabels, labels)) { + setShowLabelMatchErr(true); + return; + } + + setShowLabelMatchErr(false); + setShowScript(true); + } + + return ( + + + + + Generate Command + + null} disableProceed={true} /> + + ); } diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx index 0965d0245330c..9754fb1567806 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AwsRegionSelector.tsx @@ -15,22 +15,25 @@ */ import React, { useState } from 'react'; -import { Box, ButtonPrimary, Text, Flex } from 'design'; +import { Box, ButtonPrimary, Text, Flex, ButtonSecondary } from 'design'; import FieldSelect from 'shared/components/FieldSelect'; import { Option } from 'shared/components/Select'; import { requiredField } from 'shared/components/Validation/rules'; import Validation, { Validator } from 'shared/components/Validation'; +import { Refresh as RefreshIcon } from 'design/Icon'; import { awsRegionMap, Regions } from 'teleport/services/integrations'; export function AwsRegionSelector({ onFetch, - disableBtn, + onRefresh, + disableFetch, disableSelector, clear, }: { onFetch(region: Regions): void; - disableBtn: boolean; + onRefresh(): void; + disableFetch: boolean; disableSelector: boolean; clear(): void; }) { @@ -51,7 +54,7 @@ export function AwsRegionSelector({ return ( {({ validator }) => ( - <> + Select the AWS Region you would like to see databases for: @@ -69,17 +72,36 @@ export function AwsRegionSelector({ isDisabled={disableSelector} /> - handleFetch(validator)} - width="160px" - height="40px" - mt={1} - > - Fetch Databases - + + handleFetch(validator)} + width="160px" + height="40px" + mt={1} + > + Fetch Databases + + + + +
- +
)} ); diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx index 18e745e236cfe..92cc7a7d4599e 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.story.tsx @@ -28,7 +28,8 @@ export default { export const AwsRegionsSelectorDisabled = () => ( null} - disableBtn={true} + disableFetch={true} + onRefresh={() => null} disableSelector={true} clear={() => null} /> @@ -37,7 +38,18 @@ export const AwsRegionsSelectorDisabled = () => ( export const AwsRegionsSelectorEnabled = () => ( null} - disableBtn={false} + disableFetch={false} + onRefresh={() => null} + disableSelector={false} + clear={() => null} + /> +); + +export const AwsRegionsSelectorRefreshEnabled = () => ( + null} + disableFetch={true} + onRefresh={() => null} disableSelector={false} clear={() => null} /> @@ -79,7 +91,7 @@ const fixtures: AwsRdsDatabase[] = [ engine: 'postgres', uri: '', labels: [], - status: 'Available', + status: 'available', accountId: '', resourceId: '', }, @@ -88,7 +100,7 @@ const fixtures: AwsRdsDatabase[] = [ engine: 'mysql', uri: '', labels: [], - status: 'Available', + status: 'available', accountId: '', resourceId: '', }, @@ -100,7 +112,7 @@ const fixtures: AwsRdsDatabase[] = [ { name: 'env', value: 'prod' }, { name: 'os', value: 'windows' }, ], - status: 'Deleting', + status: 'deleting', accountId: '', resourceId: '', }, @@ -109,7 +121,7 @@ const fixtures: AwsRdsDatabase[] = [ engine: 'postgres', uri: '', labels: [], - status: 'Failed', + status: 'failed', accountId: '', resourceId: '', }, @@ -131,7 +143,7 @@ const fixtures: AwsRdsDatabase[] = [ engine: 'postgres', uri: '', labels: [{ name: 'testing-name', value: 'testing-value' }], - status: 'Available', + status: 'available', accountId: '', resourceId: '', }, diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index a33bfbbde038d..b7ed9612bc2dd 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -62,7 +62,7 @@ export function EnrollRdsDatabase() { nextStep, } = useCreateDatabase(); - const { agentMeta, resourceSpec } = useDiscover(); + const { agentMeta, resourceSpec, emitErrorEvent } = useDiscover(); const { attempt: fetchDbAttempt, setAttempt: setFetchDbAttempt } = useAttempt(''); @@ -82,6 +82,11 @@ export function EnrollRdsDatabase() { fetchDatabases({ ...tableData }); } + function refreshDatabaseList() { + // When refreshing, start the table back at page 1. + fetchDatabases({ ...tableData, startKey: '', items: [] }); + } + function fetchDatabases(data: TableData) { const integrationName = (agentMeta as DbMeta).integrationName; @@ -110,6 +115,7 @@ export function EnrollRdsDatabase() { .catch((err: Error) => { setFetchDbAttempt({ status: 'failed', statusText: err.message }); setTableData(data); // fallback to previous data + emitErrorEvent(`failed to fetch aws rds list: ${err.message}`); }); } @@ -128,9 +134,6 @@ export function EnrollRdsDatabase() { } function handleOnProceed() { - // Append `teleport_` to the RDS db name to - // lower the chance of duplicate db name. - registerDatabase( { name: selectedDb.name, @@ -157,9 +160,10 @@ export function EnrollRdsDatabase() { )} 0 } /> @@ -177,7 +181,7 @@ export function EnrollRdsDatabase() { {registerAttempt.status !== '' && ( - Database users must allow{' '} - - IAM authentication - {' '} - in order to be used with Database Access for RDS. To enable, users - must have a rds_iam role: + Users must have an rds_iam role: - Database users must allow{' '} - - IAM authentication - {' '} - in order to be used with Database Access for RDS. Users must - have the RDS authentication plugin enabled: + Users must have the RDS authentication plugin enabled: = { kind: ResourceKind.Database, wrapper(component: React.ReactNode) { return {component}; }, + shouldPrompt(currentStep, resourceSpec) { + if (resourceSpec.dbMeta?.location === DatabaseLocation.Aws) { + // Allow user to bypass prompting on this step (Connect AWS Connect) + // on exit because users might need to change route to setup an + // integration. + if (currentStep === 0) { + return false; + } + } + return true; + }, views(resource) { let configureResourceViews; if (resource && resource.dbMeta) { @@ -44,20 +56,20 @@ export const DatabaseResource: ResourceViewConfig = { case DatabaseLocation.Aws: configureResourceViews = [ { - title: 'Register a Database', - component: CreateDatabase, - eventName: DiscoverEvent.DatabaseRegister, + title: 'Connect AWS Account', + component: ConnectAwsAccount, + eventName: DiscoverEvent.IntegrationAWSOIDCConnectEvent, + }, + { + title: 'Enroll RDS Database', + component: EnrollRdsDatabase, + eventName: DiscoverEvent.DatabaseRDSEnrollEvent, }, { title: 'Deploy Database Service', component: DownloadScript, eventName: DiscoverEvent.DeployService, }, - { - title: 'Configure IAM Policy', - component: IamPolicy, - eventName: DiscoverEvent.DatabaseConfigureIAMPolicy, - }, ]; break; diff --git a/web/packages/teleport/src/Discover/Database/util.ts b/web/packages/teleport/src/Discover/Database/util.ts index 6d9d8e3419035..5e71d3f9805b0 100644 --- a/web/packages/teleport/src/Discover/Database/util.ts +++ b/web/packages/teleport/src/Discover/Database/util.ts @@ -16,103 +16,68 @@ import type { AgentLabel } from 'teleport/services/agents'; -// makeLabelMaps makes a few lookup tables out of the label prop -// for easy lookup: -// - lookup table with label.name as key, and label.value as value -// - lookup table with label.value as key, and label.key as value -// - lookup table of flags with label.name as key, and booleans as value -// which serves to record seen labels. -export function makeLabelMaps(labels: AgentLabel[]) { - let labelKeysToMatchMap: Record = {}; - let labelValsToMatchMap: Record = {}; - let labelToMatchSeenMap: Record = {}; +export function matchLabels( + newDbLabels: AgentLabel[], + matcherLabels: Record +) { + // Sorting to match by asteriks sooner. + const entries = Object.entries({ ...matcherLabels }).sort(); - labels.forEach(label => { - labelKeysToMatchMap[label.name] = label.value; - labelToMatchSeenMap[label.name] = false; - labelValsToMatchMap[label.value] = label.name; - }); + if (!entries.length) { + return false; + } - return { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap }; -} + // Create a map for db labels for easy lookup. + let dbKeyMap = {}; + let dbValMap = {}; -// matchLabels will go through each `matcherlabels` and record matched labels. -// If all labels are matched (or all asteriks exists in `matcherLabels`), -// returns true. -// -// It will return false when: -// - there were no labels to match -// - `matcherLabel` contains a label that isn't seen in the `xxxToMatchMap` -export function matchLabels({ - hasLabelsToMatch, - matcherLabels, - labelKeysToMatchMap, - labelValsToMatchMap, - labelToMatchSeenMap, -}: { - hasLabelsToMatch: boolean; - matcherLabels: Record; - labelKeysToMatchMap: Record; - labelValsToMatchMap: Record; - labelToMatchSeenMap: Record; -}) { - const matchedLabelMap = { ...labelToMatchSeenMap }; - // Sorted to have asteriks be the first label key to test. - const entries = Object.entries({ ...matcherLabels }).sort(); - for (const [key, vals] of entries) { - // Check if the label contains asteriks, which means match all eg: - // a service with match all can pick up any database regardless of other labels + newDbLabels.forEach(label => { + dbKeyMap[label.name] = label.value; + dbValMap[label.value] = label.name; + }); + + // All matching labels must make a match with the new database labels. + let matched = true; + for (let i = 0; i < entries.length; i++) { + const [key, vals] = entries[i]; + // Check if this label set contains asteriks, which means match all. + // A service with match all can pick up any database regardless of other labels // or no labels. const foundAsterikAsValue = vals.includes('*'); if (key === '*' && foundAsterikAsValue) { return true; } - if (!hasLabelsToMatch) { - return false; + // If no newDbLabels labels were defined, there are no matches to make, + // which makes this service not a match. + if (!newDbLabels.length) { + matched = false; + break; } - // Start matching by value. + // Start matching by values. - // This means any key is fine, as long as value matches. - if (key === '*') { - let found = false; - vals.forEach(val => { - const key = labelValsToMatchMap[val]; - if (key) { - matchedLabelMap[key] = true; - found = true; - } - }); - if (found) { - continue; - } + // This means any key is fine, as long as a value matches. + if (key === '*' && vals.find(val => dbValMap[val])) { + continue; } // This means any value is fine, as long as a key matches. - // Note that db resource labels can't have duplicate keys - // (but db service can). - else if (foundAsterikAsValue && labelKeysToMatchMap[key]) { - matchedLabelMap[key] = true; + if (foundAsterikAsValue && dbKeyMap[key]) { continue; } - // Match against actual values of key and its value. - else { - const dbVal = labelKeysToMatchMap[key]; - if (dbVal && vals.find(val => val === dbVal)) { - matchedLabelMap[key] = true; - continue; - } + // Match against key and value. + const dbVal = dbKeyMap[key]; + if (dbVal && vals.find(val => val === dbVal)) { + continue; } - // At this point, the current label did not match any criteria, - // we can abort, since it takes only one mismatch to fail. - return false; - } + // No matches were found for this label set which + // means this service not a match. + matched = false; + break; + } // label set loop - return ( - hasLabelsToMatch && - Object.keys(matchedLabelMap).every(key => matchedLabelMap[key]) - ); + return matched; } diff --git a/web/packages/teleport/src/Discover/Database/utils.test.ts b/web/packages/teleport/src/Discover/Database/utils.test.ts new file mode 100644 index 0000000000000..a606758ecb811 --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/utils.test.ts @@ -0,0 +1,113 @@ +/** + * 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 { matchLabels } from './util'; + +const newDbLabels = [ + { name: 'env', value: 'prod' }, + { name: 'os', value: 'mac' }, + { name: 'tag', value: 'v11.0.0' }, +]; + +const testCases = [ + { + name: 'match multiple by exact keys and values', + match: true, + matcherLabels: { + env: ['prod'], + os: ['mac'], + tag: ['v11.0.0'], + }, + }, + { + name: 'match by multivalues', + match: true, + matcherLabels: { + env: ['prod', 'staging'], + os: ['windows', 'mac', 'linux'], + tag: ['1', '2', '3', 'v11.0.0'], + }, + }, + { + name: 'match with one label set', + match: true, + matcherLabels: { + tag: ['v1', 'v11.0.0', 'v2'], + }, + }, + { + name: 'match by asteriks', + match: true, + matcherLabels: { + env: ['na'], + os: ['na'], + '*': ['*'], + }, + }, + { + name: 'match with a key and value asterik', + match: true, + matcherLabels: { + '*': ['prod', 'staging'], + os: ['*'], + }, + }, + { + name: 'match by asteriks with no db labels defined', + noDbLabels: true, + match: true, + matcherLabels: { '*': ['*'] }, + }, + { + name: 'no match with no db labels defined and no matcher labels', + noDbLabels: true, + match: false, + matcherLabels: {}, + }, + { + name: 'no match with no db labels with matcher labels', + noDbLabels: true, + match: false, + matcherLabels: { os: ['mac'] }, + }, + { + name: 'no match despite other matching labels', + match: false, + matcherLabels: { + '*': ['no-match'], // no match + env: ['prod'], + os: ['mac'], + tag: ['v11.0.0'], + }, + }, + { + name: 'no match with empty labels', + match: false, + matcherLabels: {}, + }, + { + name: 'no match with empty label values', + match: false, + matcherLabels: { os: [] }, + }, +]; + +describe('matchLabels()', () => { + test.each(testCases)('$name', ({ matcherLabels, match, noDbLabels }) => { + const isMatched = matchLabels(noDbLabels ? [] : newDbLabels, matcherLabels); + expect(isMatched).toEqual(match); + }); +}); diff --git a/web/packages/teleport/src/Discover/Discover.tsx b/web/packages/teleport/src/Discover/Discover.tsx index 629aa6a754949..35ed0c6d0fa12 100644 --- a/web/packages/teleport/src/Discover/Discover.tsx +++ b/web/packages/teleport/src/Discover/Discover.tsx @@ -77,7 +77,7 @@ function DiscoverContent() { }} when={ viewConfig.shouldPrompt - ? viewConfig.shouldPrompt(currentStep) + ? viewConfig.shouldPrompt(currentStep, agentProps.resourceSpec) : true } /> diff --git a/web/packages/teleport/src/Discover/Navigation/StepItem.tsx b/web/packages/teleport/src/Discover/Navigation/StepItem.tsx index b8a32c4a75d5f..f41c5714d7a3a 100644 --- a/web/packages/teleport/src/Discover/Navigation/StepItem.tsx +++ b/web/packages/teleport/src/Discover/Navigation/StepItem.tsx @@ -18,6 +18,8 @@ import React from 'react'; import styled from 'styled-components'; import { Flex } from 'design'; +import { icons } from 'teleport/Discover/SelectResource/icons'; + import { StepList } from './StepList'; import type { View } from 'teleport/Discover/flow'; @@ -51,7 +53,7 @@ export function StepItem(props: StepItemProps) { {getBulletIcon({ - Icon: props.selectedResource.Icon, + Icon: icons[props.selectedResource.icon], })} {props.selectedResource.name} @@ -155,6 +157,7 @@ const CheckedBullet = styled(Bullet)` :before { content: '✓'; + color: ${props => props.theme.colors.levels.popout}; } `; diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index b750afbb7def1..5c78c3fc92fac 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -34,6 +34,8 @@ import { RESOURCES, } from 'teleport/Discover/SelectResource/resources'; +import { icons } from './icons'; + import type { ResourceSpec } from './types'; import type { AddButtonResourceKind } from 'teleport/components/AgentButtonAdd/AgentButtonAdd'; @@ -159,7 +161,7 @@ export function SelectResource(props: SelectResourceProps) { )} - {r.Icon} + {icons[r.icon]} {pretitle && ( diff --git a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap index 83056a168e335..cad9306d055dc 100644 --- a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap @@ -1238,6 +1238,47 @@ exports[`render with all access 1`] = ` > Amazon Web Services (AWS) +
+ Aurora PostgreSQL +
+ + + +
+
+ Guided +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
@@ -1246,6 +1287,47 @@ exports[`render with all access 1`] = `
+
+
+ Guided +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ Aurora MySQL/MariaDB +
+
+
+
Amazon Web Services (AWS)
+
+ Aurora PostgreSQL +
+
+ + +
+
+ Lacking Permissions +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
@@ -2888,6 +3012,48 @@ exports[`render with no access 1`] = ` > Lacking Permissions
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ Aurora MySQL/MariaDB +
+
+
+
+
+
+ Lacking Permissions +
@@ -4625,6 +4791,48 @@ exports[`render with partial access 1`] = ` > Amazon Web Services (AWS)
+
+ Aurora PostgreSQL +
+
+
+
+
+
+ Lacking Permissions +
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
@@ -4642,6 +4850,48 @@ exports[`render with partial access 1`] = ` > Lacking Permissions
+
+
+ +
+
+
+ Amazon Web Services (AWS) +
+
+ Aurora MySQL/MariaDB +
+
+
+
+
+
+ Lacking Permissions +
diff --git a/web/packages/teleport/src/Discover/SelectResource/databases.tsx b/web/packages/teleport/src/Discover/SelectResource/databases.tsx index 3c11789dd8308..2666f9ebfd0d1 100644 --- a/web/packages/teleport/src/Discover/SelectResource/databases.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/databases.tsx @@ -20,7 +20,6 @@ import { DiscoverEventResource } from 'teleport/services/userEvent'; import { ResourceKind } from '../Shared/ResourceKind'; -import { icons } from './icons'; import { ResourceSpec, DatabaseLocation, DatabaseEngine } from './types'; const baseDatabaseKeywords = 'db database databases'; @@ -41,7 +40,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ name: 'RDS Proxy', keywords: awsKeywords + 'rds proxy', kind: ResourceKind.Database, - Icon: icons.Aws, + icon: 'Aws', unguidedLink: getDbAccessDocLink('rds-proxy'), event: DiscoverEventResource.DatabaseDocRdsProxy, }, @@ -50,7 +49,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ name: 'High Availability', keywords: baseDatabaseKeywords + 'high availability ha', kind: ResourceKind.Database, - Icon: icons.Database, + icon: 'Database', unguidedLink: getDbAccessDocLink('ha'), event: DiscoverEventResource.DatabaseDocHighAvailability, }, @@ -59,7 +58,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ name: 'Dynamic Registration', keywords: baseDatabaseKeywords + 'dynamic registration', kind: ResourceKind.Database, - Icon: icons.Database, + icon: 'Database', unguidedLink: getDbAccessDocLink('dynamic-registration'), event: DiscoverEventResource.DatabaseDocDynamicRegistration, }, @@ -71,7 +70,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'DynamoDB', keywords: awsKeywords + 'dynamodb', kind: ResourceKind.Database, - Icon: icons.Dynamo, + icon: 'Dynamo', unguidedLink: getDbAccessDocLink('aws-dynamodb'), event: DiscoverEventResource.DatabaseDynamoDb, }, @@ -80,7 +79,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'ElastiCache & MemoryDB', keywords: awsKeywords + 'elasticache memorydb redis', kind: ResourceKind.Database, - Icon: icons.Aws, + icon: 'Aws', unguidedLink: getDbAccessDocLink('redis-aws'), event: DiscoverEventResource.DatabaseRedisElasticache, }, @@ -92,7 +91,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Keyspaces (Apache Cassandra)', keywords: awsKeywords + 'keyspaces apache cassandra', kind: ResourceKind.Database, - Icon: icons.Aws, + icon: 'Aws', unguidedLink: getDbAccessDocLink('aws-cassandra-keyspaces'), event: DiscoverEventResource.DatabaseCassandraKeyspaces, }, @@ -101,7 +100,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Redshift PostgreSQL', keywords: awsKeywords + 'redshift postgresql', kind: ResourceKind.Database, - Icon: icons.Redshift, + icon: 'Redshift', unguidedLink: getDbAccessDocLink('postgres-redshift'), event: DiscoverEventResource.DatabasePostgresRedshift, }, @@ -110,7 +109,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Redshift Serverless', keywords: awsKeywords + 'redshift serverless postgresql', kind: ResourceKind.Database, - Icon: icons.Redshift, + icon: 'Redshift', unguidedLink: getDbAccessDocLink('redshift-serverless'), event: DiscoverEventResource.DatabasePostgresRedshiftServerless, }, @@ -119,7 +118,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Cache for Redis', keywords: azureKeywords + 'cache redis', kind: ResourceKind.Database, - Icon: icons.Azure, + icon: 'Azure', unguidedLink: getDbAccessDocLink('azure-redis'), event: DiscoverEventResource.DatabaseRedisAzureCache, }, @@ -131,7 +130,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'PostgreSQL', keywords: azureKeywords + 'postgresql', kind: ResourceKind.Database, - Icon: icons.Azure, + icon: 'Azure', unguidedLink: getDbAccessDocLink('azure-postgres-mysql'), event: DiscoverEventResource.DatabasePostgresAzure, }, @@ -140,7 +139,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'MySQL', keywords: azureKeywords + 'mysql', kind: ResourceKind.Database, - Icon: icons.Azure, + icon: 'Azure', unguidedLink: getDbAccessDocLink('azure-postgres-mysql'), event: DiscoverEventResource.DatabaseMysqlAzure, }, @@ -153,7 +152,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ keywords: azureKeywords + 'active directory ad sql server sqlserver preview', kind: ResourceKind.Database, - Icon: icons.Azure, + icon: 'Azure', unguidedLink: getDbAccessDocLink('azure-sql-server-ad'), event: DiscoverEventResource.DatabaseSqlServerAzure, }, @@ -167,7 +166,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ baseDatabaseKeywords + 'microsoft active directory ad sql server sqlserver preview', kind: ResourceKind.Database, - Icon: icons.Windows, + icon: 'Windows', unguidedLink: getDbAccessDocLink('sql-server-ad'), event: DiscoverEventResource.DatabaseSqlServerMicrosoft, }, @@ -176,7 +175,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Cloud SQL MySQL', keywords: gcpKeywords + 'mysql', kind: ResourceKind.Database, - Icon: icons.Gcp, + icon: 'Gcp', unguidedLink: getDbAccessDocLink('mysql-cloudsql'), event: DiscoverEventResource.DatabaseMysqlGcp, }, @@ -185,7 +184,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Cloud SQL PostgreSQL', keywords: gcpKeywords + 'postgresql', kind: ResourceKind.Database, - Icon: icons.Gcp, + icon: 'Gcp', unguidedLink: getDbAccessDocLink('postgres-cloudsql'), event: DiscoverEventResource.DatabasePostgresGcp, }, @@ -197,7 +196,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'MongoDB Atlas', keywords: baseDatabaseKeywords + 'mongodb atlas', kind: ResourceKind.Database, - Icon: icons.Mongo, + icon: 'Mongo', unguidedLink: getDbAccessDocLink('mongodb-atlas'), event: DiscoverEventResource.DatabaseMongodbAtlas, }, @@ -209,7 +208,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Cassandra & ScyllaDB', keywords: selfhostedKeywords + 'cassandra scylladb', kind: ResourceKind.Database, - Icon: icons.SelfHosted, + icon: 'SelfHosted', unguidedLink: getDbAccessDocLink('cassandra-self-hosted'), event: DiscoverEventResource.DatabaseCassandraSelfHosted, }, @@ -221,7 +220,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'CockroachDB', keywords: selfhostedKeywords + 'cockroachdb', kind: ResourceKind.Database, - Icon: icons.Cockroach, + icon: 'Cockroach', unguidedLink: getDbAccessDocLink('cockroachdb-self-hosted'), event: DiscoverEventResource.DatabaseCockroachDbSelfHosted, }, @@ -233,7 +232,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Elasticsearch', keywords: selfhostedKeywords + 'elasticsearch', kind: ResourceKind.Database, - Icon: icons.SelfHosted, + icon: 'SelfHosted', unguidedLink: getDbAccessDocLink('elastic'), event: DiscoverEventResource.DatabaseElasticSearchSelfHosted, }, @@ -245,7 +244,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'MongoDB', keywords: selfhostedKeywords + 'mongodb', kind: ResourceKind.Database, - Icon: icons.Mongo, + icon: 'Mongo', unguidedLink: getDbAccessDocLink('mongodb-self-hosted'), event: DiscoverEventResource.DatabaseMongodbSelfHosted, }, @@ -257,7 +256,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Redis', keywords: selfhostedKeywords + 'redis', kind: ResourceKind.Database, - Icon: icons.SelfHosted, + icon: 'SelfHosted', unguidedLink: getDbAccessDocLink('redis'), event: DiscoverEventResource.DatabaseRedisSelfHosted, }, @@ -269,7 +268,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Redis Cluster', keywords: selfhostedKeywords + 'redis cluster', kind: ResourceKind.Database, - Icon: icons.SelfHosted, + icon: 'SelfHosted', unguidedLink: getDbAccessDocLink('redis-cluster'), event: DiscoverEventResource.DatabaseRedisClusterSelfHosted, }, @@ -281,7 +280,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ name: 'Snowflake (Preview)', keywords: baseDatabaseKeywords + 'snowflake preview', kind: ResourceKind.Database, - Icon: icons.Snowflake, + icon: 'Snowflake', unguidedLink: getDbAccessDocLink('snowflake'), event: DiscoverEventResource.DatabaseSnowflake, }, @@ -296,7 +295,18 @@ export const DATABASES: ResourceSpec[] = [ name: 'RDS PostgreSQL', keywords: awsKeywords + 'rds postgresql', kind: ResourceKind.Database, - Icon: icons.Aws, + icon: 'Aws', + event: DiscoverEventResource.DatabasePostgresRds, + }, + { + dbMeta: { + location: DatabaseLocation.Aws, + engine: DatabaseEngine.AuroraPostgres, + }, + name: 'Aurora PostgreSQL', + keywords: awsKeywords + 'aurora postgresql', + kind: ResourceKind.Database, + icon: 'Aws', event: DiscoverEventResource.DatabasePostgresRds, }, { @@ -304,7 +314,18 @@ export const DATABASES: ResourceSpec[] = [ name: 'RDS MySQL/MariaDB', keywords: awsKeywords + 'rds mysql mariadb', kind: ResourceKind.Database, - Icon: icons.Aws, + icon: 'Aws', + event: DiscoverEventResource.DatabaseMysqlRds, + }, + { + dbMeta: { + location: DatabaseLocation.Aws, + engine: DatabaseEngine.AuroraMysql, + }, + name: 'Aurora MySQL/MariaDB', + keywords: awsKeywords + 'aurora mysql mariadb', + kind: ResourceKind.Database, + icon: 'Aws', event: DiscoverEventResource.DatabaseMysqlRds, }, { @@ -315,7 +336,7 @@ export const DATABASES: ResourceSpec[] = [ name: 'PostgreSQL', keywords: selfhostedKeywords + 'postgresql', kind: ResourceKind.Database, - Icon: icons.Postgres, + icon: 'Postgres', event: DiscoverEventResource.DatabasePostgresSelfHosted, }, { @@ -326,7 +347,7 @@ export const DATABASES: ResourceSpec[] = [ name: 'MySQL/MariaDB', keywords: selfhostedKeywords + 'mysql mariadb', kind: ResourceKind.Database, - Icon: icons.SelfHosted, + icon: 'SelfHosted', event: DiscoverEventResource.DatabaseMysqlSelfHosted, }, ]; diff --git a/web/packages/teleport/src/Discover/SelectResource/icons.tsx b/web/packages/teleport/src/Discover/SelectResource/icons.tsx index 3e6e8a01fa7e2..6a6fa871cbbb1 100644 --- a/web/packages/teleport/src/Discover/SelectResource/icons.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/icons.tsx @@ -65,3 +65,5 @@ export const icons = { Windows: , Unknown: , }; + +export type ResourceIconName = keyof typeof icons; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources.tsx index 4236897602cdc..1e5b0a5d91f23 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources.tsx @@ -24,7 +24,6 @@ import { DATABASES_UNGUIDED_DOC, } from './databases'; import { ResourceSpec, DatabaseLocation, DatabaseEngine } from './types'; -import { icons } from './icons'; const baseServerKeywords = 'server node'; export const SERVERS: ResourceSpec[] = [ @@ -32,35 +31,35 @@ export const SERVERS: ResourceSpec[] = [ name: 'Ubuntu 14.04+', kind: ResourceKind.Server, keywords: baseServerKeywords + 'ubuntu', - Icon: icons.Linux, + icon: 'Linux', event: DiscoverEventResource.Server, }, { name: 'Debian 8+', kind: ResourceKind.Server, keywords: baseServerKeywords + 'debian', - Icon: icons.Linux, + icon: 'Linux', event: DiscoverEventResource.Server, }, { name: 'RHEL/CentOS 7+', kind: ResourceKind.Server, keywords: baseServerKeywords + 'rhel centos', - Icon: icons.Linux, + icon: 'Linux', event: DiscoverEventResource.Server, }, { name: 'Amazon Linux 2', kind: ResourceKind.Server, keywords: baseServerKeywords + 'amazon linux', - Icon: icons.Aws, + icon: 'Aws', event: DiscoverEventResource.Server, }, { name: 'macOS (Intel)', kind: ResourceKind.Server, keywords: baseServerKeywords + 'mac macos intel', - Icon: icons.Apple, + icon: 'Apple', event: DiscoverEventResource.Server, }, ]; @@ -70,7 +69,7 @@ export const APPLICATIONS: ResourceSpec[] = [ name: 'Application', kind: ResourceKind.Application, keywords: 'application', - Icon: icons.Application, + icon: 'Application', unguidedLink: 'https://goteleport.com/docs/application-access/getting-started/', event: DiscoverEventResource.ApplicationHttp, @@ -82,7 +81,7 @@ export const WINDOWS_DESKTOPS: ResourceSpec[] = [ name: 'Active Directory', kind: ResourceKind.Desktop, keywords: 'windows desktop active directory ad', - Icon: icons.Windows, + icon: 'Windows', event: DiscoverEventResource.WindowsDesktop, }, // { @@ -99,7 +98,7 @@ export const KUBERNETES: ResourceSpec[] = [ name: 'Kubernetes', kind: ResourceKind.Kubernetes, keywords: 'kubernetes cluster kubes', - Icon: icons.Kube, + icon: 'Kube', event: DiscoverEventResource.Kubernetes, }, ]; diff --git a/web/packages/teleport/src/Discover/SelectResource/types.ts b/web/packages/teleport/src/Discover/SelectResource/types.ts index ba1f284ff7f62..286f7de3921b6 100644 --- a/web/packages/teleport/src/Discover/SelectResource/types.ts +++ b/web/packages/teleport/src/Discover/SelectResource/types.ts @@ -17,6 +17,7 @@ import { ResourceKind } from '../Shared/ResourceKind'; import type { DiscoverEventResource } from 'teleport/services/userEvent'; +import type { ResourceIconName } from './icons'; export enum DatabaseLocation { Aws, @@ -52,7 +53,7 @@ export interface ResourceSpec { name: string; popular?: boolean; kind: ResourceKind; - Icon: React.ReactElement; + icon: ResourceIconName; // keywords are filter words that user may use to search for // this resource. keywords: string; diff --git a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx index 8a802c1a191ec..06c617c07b275 100644 --- a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.tsx @@ -72,7 +72,7 @@ export function SetupAccess(props: State) { const hasTraits = selectedLogins.length > 0; const canAddTraits = !props.isSsoUser && props.canEditUser; const headerSubtitle = - 'Select the OS users you will use to connect to server.'; + 'Select or create the OS users you will use to connect to server.'; return ( = (t: T) => View[]; export interface ResourceViewConfig { @@ -41,7 +43,7 @@ export interface ResourceViewConfig { // by "currentStep" param). // Not supplying a function is equivalent to always prompting // on exit or changing route. - shouldPrompt?: (currentStep: number) => boolean; + shouldPrompt?: (currentStep: number, resourceSpec: ResourceSpec) => boolean; } export interface View { diff --git a/web/packages/teleport/src/Discover/useDiscover.test.tsx b/web/packages/teleport/src/Discover/useDiscover.test.tsx index a1623a10f8196..8293d74e80452 100644 --- a/web/packages/teleport/src/Discover/useDiscover.test.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.test.tsx @@ -292,6 +292,8 @@ describe('emitting events', () => { resource: DiscoverEventResource.Server, stepStatus: DiscoverEventStatus.Error, stepStatusError: 'some error message', + selectedResourcesCount: 0, + autoDiscoverResourcesCount: 0, }, }) ); diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index 875560ddf724a..ec7db069ef4e6 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -15,7 +15,7 @@ */ import React, { useContext, useState, useEffect, useCallback } from 'react'; -import { useHistory } from 'react-router'; +import { useHistory, useLocation } from 'react-router'; import { DiscoverEventStatus, @@ -66,6 +66,7 @@ type CustomEventInput = { eventName?: DiscoverEvent; eventResourceName?: DiscoverEventResource; autoDiscoverResourcesCount?: number; + selectedResourcesCount?: number; }; type DiscoverProviderProps = { @@ -73,18 +74,36 @@ type DiscoverProviderProps = { mockCtx?: DiscoverContextState; }; +// DiscoverUrlLocationState define fields to preserve state between +// react routes (eg. in RDS database flow, it is required of user +// to create a AWS OIDC integration which requires changing route +// and then coming back to resume the flow.) +export type DiscoverUrlLocationState = { + // discover contains the fields necessary to be able to resume + // the flow from where user left off. + discover: { + eventState: EventState; + resourceSpec: ResourceSpec; + currentStep: number; + }; + // integrationName is the name of the created integration + // resource name (eg: integration subkind "aws-oidc") + integrationName: string; +}; + const discoverContext = React.createContext(null); export function DiscoverProvider( props: React.PropsWithChildren ) { const history = useHistory(); + const location = useLocation(); const [currentStep, setCurrentStep] = useState(0); const [agentMeta, setAgentMeta] = useState(); const [resourceSpec, setResourceSpec] = useState(); const [viewConfig, setViewConfig] = useState(); - const [eventState, setEventState] = useState(); + const [eventState, setEventState] = useState({} as any); // indexedViews contains views of the selected resource where // each view has been assigned an index value. @@ -100,6 +119,7 @@ export function DiscoverProvider( id, resource: custom?.eventResourceName || resourceSpec?.event, autoDiscoverResourcesCount: custom?.autoDiscoverResourcesCount, + selectedResourcesCount: custom?.selectedResourcesCount, ...status, }, }); @@ -135,12 +155,14 @@ export function DiscoverProvider( }, [eventState, history.location.pathname, emitEvent]); useEffect(() => { - initEventState(); + if (location.state?.discover) { + resumeDiscoverFlow(); + } else { + initEventState(); + } }, []); function initEventState() { - // Generates a v4 UUID using a cryptographically secure - // random number. const id = crypto.randomUUID(); setEventState({ @@ -160,11 +182,46 @@ export function DiscoverProvider( }); } - // onSelectResources initializes all the required - // variables needed to start a guided flow. + // If a location.state.discover was provided, that means the user is + // coming back from another location to resume the flow. + // Users will resume at the step that is +1 from the step they left from. + // + // Example (only applies to AWS RDS & Aurora resources): + // A user can leave from route `web/discover/` + // to `web/integrations/enroll/` then + // come back to resume flow at `web/discover/` + // + // Resuming flow at `Enroll RDS Database` means the user has + // successfully finished the prior `Connect AWS Account` step, + // so we emit a success event for that step. + // + // The location.state.discover should contain all the state that allows + // the user to resume from where they left of. + function resumeDiscoverFlow() { + const { discover, integrationName } = location.state; + + updateAgentMeta({ integrationName } as DbMeta); + + startDiscoverFlow( + discover.resourceSpec, + discover.eventState, + discover.currentStep + 1 + ); + + emitEvent( + { stepStatus: DiscoverEventStatus.Success }, + { + eventName: discover.eventState.currEventName, + eventResourceName: discover.resourceSpec.event, + } + ); + } + + // onSelectResources inits states, starts flow, and + // emits events. function onSelectResource(resource: ResourceSpec) { - // We still want to emit an event if user clicked on - // unguided links to gather data on which unguided resource + // We still want to emit an event if user clicked on an + // unguided link to gather data on which unguided resource // is most popular. if (resource.unguidedLink) { emitEvent( @@ -177,6 +234,27 @@ export function DiscoverProvider( return; } + startDiscoverFlow(resource, eventState); + + // At this point it's considered the user has + // successfully selected a resource, so we send an event + // for it. + emitEvent( + { stepStatus: DiscoverEventStatus.Success }, + { + eventName: DiscoverEvent.ResourceSelection, + eventResourceName: resource.event, + } + ); + } + + // startDiscoverFlow sets all the required states + // that will begin the flow. + function startDiscoverFlow( + resource: ResourceSpec, + initEventState: EventState, + targetViewIndex = 0 + ) { // Process each view and assign each with an index number. const currCfg = viewConfigs.find(r => r.kind === resource.kind); let indexedViews = []; @@ -186,30 +264,22 @@ export function DiscoverProvider( indexedViews = addIndexToViews(currCfg.views); } - // Find the first view to update the event state. + // Find the target view to update the event state. const { eventName, manuallyEmitSuccessEvent } = findViewAtIndex( indexedViews, - currentStep - ); - // At this point it's considered the user has - // successfully selected a resource, so we send an event. - emitEvent( - { stepStatus: DiscoverEventStatus.Success }, - { - eventName: DiscoverEvent.ResourceSelection, - eventResourceName: resource.event, - } + targetViewIndex ); // Init all required states to start the flow. setEventState({ - ...eventState, + ...initEventState, currEventName: eventName, manuallyEmitSuccessEvent, }); setViewConfig(currCfg); setIndexedViews(indexedViews); setResourceSpec(resource); + setCurrentStep(targetViewIndex); } // nextStep takes the user to next screen and sends reporting events. @@ -247,7 +317,17 @@ export function DiscoverProvider( if (!numToIncrement) { emitEvent({ stepStatus: DiscoverEventStatus.Skipped }); } else if (!eventState.manuallyEmitSuccessEvent) { - emitEvent({ stepStatus: DiscoverEventStatus.Success }); + // TODO(lisa): Currently the RDS enroll screen only allows + // user to select one RDS database to enroll so we hard code + // it for now. + if (eventState.currEventName === DiscoverEvent.DatabaseRDSEnrollEvent) { + emitEvent( + { stepStatus: DiscoverEventStatus.Success }, + { selectedResourcesCount: 1 } + ); + } else { + emitEvent({ stepStatus: DiscoverEventStatus.Success }); + } } // Whenever a numToIncrement is > 1, it means some steps (after the current view) @@ -288,10 +368,13 @@ export function DiscoverProvider( } function emitErrorEvent(errorStr = '') { - emitEvent({ - stepStatus: DiscoverEventStatus.Error, - stepStatusError: errorStr, - }); + emitEvent( + { + stepStatus: DiscoverEventStatus.Error, + stepStatusError: errorStr, + }, + { autoDiscoverResourcesCount: 0, selectedResourcesCount: 0 } + ); } const value: DiscoverContextState = { @@ -342,6 +425,8 @@ export type NodeMeta = BaseMeta & { // DbMeta describes the fields for a db resource // that needs to be preserved throughout the flow. export type DbMeta = BaseMeta & { + // TODO(lisa): when we can enroll multiple RDS's, turn this into an array? + // The enroll event expects num count of enrolled RDS's, update accordingly. db: Database; integrationName?: string; }; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/FifthStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/FifthStageInstructions.tsx index 35966bb6ba8b4..23f13b8cfd161 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/FifthStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/FifthStageInstructions.tsx @@ -32,7 +32,10 @@ export function FifthStageInstructions(props: CommonInstructionsProps) { "Statement": [ { "Effect": "Allow", - "Action": "rds:DescribeDBInstances", + "Action": [ + "rds:DescribeDBInstances", + "rds:DescribeDBClusters" + ], "Resource": "*" } ] diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx index 4125dae4d58c1..2aab67528b761 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx @@ -28,6 +28,8 @@ import { SuccessfullyAddedIntegrationDialog, } from './SeventhStageInstructions'; +import type { DiscoverUrlLocationState } from 'teleport/Discover/useDiscover'; + export default { title: 'Teleport/Integrations/Enroll/AwsOidc/Instructions', }; @@ -46,19 +48,15 @@ export const Step7 = () => ( export const ConfirmDialog = () => ( - + ); export const ConfirmDialogFromDiscover = () => ( - - + + ); diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx index 324fdfdac500f..df09c181d307a 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx @@ -60,7 +60,7 @@ export function SecondStageInstructions(props: CommonInstructionsProps) { bash={false} lines={[ { - text: `https://${props.clusterPublicUri}`, + text: `https://${getClusterPublicUri(props.clusterPublicUri)}`, }, ]} /> @@ -92,6 +92,7 @@ export function SecondStageInstructions(props: CommonInstructionsProps) { <> setThumbprint(e.target.value)} value={thumbprint} @@ -110,3 +111,15 @@ export function SecondStageInstructions(props: CommonInstructionsProps) { ); } + +function getClusterPublicUri(uri: string) { + const uriParts = uri.split(':'); + const port = uriParts.length > 1 ? uriParts[1] : ''; + + // Strip 443 ports from uri. + if (port === '443') { + return uriParts[0]; + } + + return uri; +} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx index db9c2ec456f0a..d3cc7a3fbd1b5 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx @@ -37,12 +37,11 @@ import { integrationService, } from 'teleport/services/integrations'; import cfg from 'teleport/config'; +import { DiscoverUrlLocationState } from 'teleport/Discover/useDiscover'; import { InstructionsContainer } from './common'; export function SeventhStageInstructions() { - const location = useLocation<{ discoverEventId: string }>(); - const { attempt, setAttempt } = useAttempt(''); const [showConfirmBox, setShowConfirmBox] = useState(false); const [roleArn, setRoleArn] = useState(''); @@ -79,6 +78,7 @@ export function SeventhStageInstructions() { Copy the role ARN and paste it below setRoleArn(e.target.value)} value={roleArn} @@ -108,22 +108,19 @@ export function SeventhStageInstructions() { )} {showConfirmBox && ( - + )} ); } export function SuccessfullyAddedIntegrationDialog({ - discoverEventId, integrationName, }: { - discoverEventId: string; integrationName: string; }) { + const location = useLocation(); + return ( ({ maxWidth: '500px', width: '100%' })} @@ -140,16 +137,15 @@ export function SuccessfullyAddedIntegrationDialog({ - {discoverEventId ? ( + {location.state?.discover ? ( diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SixthStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SixthStageInstructions.tsx index 240dca368be3a..1b81159d2102b 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SixthStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SixthStageInstructions.tsx @@ -28,14 +28,15 @@ import type { CommonInstructionsProps } from './common'; export function SixthStageInstructions(props: CommonInstructionsProps) { return ( - Close the "Create policy tab" + + Close the tab for "Create Policy" and go back to the tab for{' '} + Create Role + Refresh the list of policies and select the policy you just created - Search for the policy you just created and select it - Click Next: Tags and then Next: Review diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/ThirdStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/ThirdStageInstructions.tsx index d96c76576907c..2d3b3033c209d 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/ThirdStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/ThirdStageInstructions.tsx @@ -38,7 +38,8 @@ export function ThirdStageInstructions(props: CommonInstructionsProps) { - Select Assign role and create a new role + Click Assign role and select{' '} + Create a new role diff --git a/web/packages/teleport/src/services/integrations/integrations.test.ts b/web/packages/teleport/src/services/integrations/integrations.test.ts index 0c665e745f6d1..f889056065685 100644 --- a/web/packages/teleport/src/services/integrations/integrations.test.ts +++ b/web/packages/teleport/src/services/integrations/integrations.test.ts @@ -123,8 +123,8 @@ test('fetchAwsDatabases response', async () => { engine: 'mysql', name: 'rds-2', uri: 'endpoint-2', - status: 'Available', labels: [], + status: undefined, accountId: undefined, resourceId: undefined, }, @@ -132,8 +132,8 @@ test('fetchAwsDatabases response', async () => { engine: 'mysql', name: 'rds-3', uri: 'endpoint-3', - status: 'Available', labels: [], + status: undefined, accountId: undefined, resourceId: undefined, }, @@ -200,9 +200,9 @@ const mockAwsDbs = [ protocol: 'postgres', name: 'rds-1', uri: 'endpoint-1', - status: 'Available', labels: [{ name: 'env', value: 'prod' }], aws: { + status: 'Available', account_id: 'account-id-1', rds: { resource_id: 'resource-id-1', @@ -214,7 +214,6 @@ const mockAwsDbs = [ protocol: 'mysql', name: 'rds-2', uri: 'endpoint-2', - status: 'Available', aws: {}, }, // Test without aws field. @@ -222,6 +221,5 @@ const mockAwsDbs = [ protocol: 'mysql', name: 'rds-3', uri: 'endpoint-3', - status: 'Available', }, ]; diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 5f3e4bb29b643..98af8fab4b009 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -133,13 +133,13 @@ function makeIntegration(json: any): Integration { export function makeAwsDatabase(json: any): AwsRdsDatabase { json = json ?? {}; - const { aws, name, uri, status, labels, protocol } = json; + const { aws, name, uri, labels, protocol } = json; return { engine: protocol, name, uri, - status, + status: aws?.status, labels: labels ?? [], resourceId: aws?.rds?.resource_id, accountId: aws?.account_id, diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 2657af5b98ea0..5a5b70502f238 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -192,7 +192,7 @@ export type AwsRdsDatabase = { // There is a lot of status states available so only a select few were // hard defined to use to determine the status color. // https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/accessing-monitoring.html - status: 'Available' | 'Failed' | 'Deleting'; + status: 'available' | 'failed' | 'deleting'; }; export type ListAwsRdsDatabaseResponse = { diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts index 2eec93a6f5e03..a35c55542ac46 100644 --- a/web/packages/teleport/src/services/user/makeAcl.ts +++ b/web/packages/teleport/src/services/user/makeAcl.ts @@ -31,10 +31,7 @@ export function makeAcl(json): Acl { const accessRequests = json.accessRequests || defaultAccess; const billing = json.billing || defaultAccess; const plugins = json.plugins || defaultAccess; - // TODO(lisa): requires backend changes to user context. - // Feature is off until all TODO related to integrations is done. - // const integrations = json.integrations || defaultAccessWithUse; - const integrations = defaultAccessWithUse; + const integrations = json.integrations || defaultAccessWithUse; const dbServers = json.dbServers || defaultAccess; const db = json.db || defaultAccess; const desktops = json.desktops || defaultAccess; From 04e28f860ebbb2f09973caa5bd93068a9a971c94 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Tue, 2 May 2023 09:34:15 -0700 Subject: [PATCH 4/6] WebDiscover: Put back IamPolicy screen and some tweaks (#25438) --- lib/usagereporter/teleport/types_discover.go | 4 +- .../FileTransferStateless/CommonElements.tsx | 2 +- .../FileList/FileListItem.tsx | 2 +- .../CreateDatabase/CreateDatabase.tsx | 2 +- .../DownloadScript/DownloadScript.tsx | 2 +- .../Discover/Database/IamPolicy/IamPolicy.tsx | 40 ++++++++++--------- .../Database/SetupAccess/SetupAccess.tsx | 2 +- .../TestConnection/TestConnection.tsx | 2 +- .../teleport/src/Discover/Database/index.tsx | 6 +++ .../Kubernetes/HelmChart/HelmChart.tsx | 2 +- .../TestConnection/TestConnection.tsx | 2 +- .../Server/TestConnection/TestConnection.tsx | 2 +- .../src/Discover/Shared/CommandBox.tsx | 3 +- .../teleport/src/Discover/Shared/HintBox.tsx | 8 ++-- .../Shared/SetupAccess/SetupAccessWrapper.tsx | 2 +- .../teleport/src/Discover/Shared/Step.tsx | 2 +- .../Integrations/Enroll/AwsOidc/AwsOidc.tsx | 16 +++++++- .../Enroll/AwsOidc/IAM/IAMCreateNewPolicy.tsx | 11 +++-- .../instructions/SecondStageInstructions.tsx | 14 +------ .../src/Navigation/NavigationSwitcher.tsx | 2 +- .../ToolTipNoPermBadge.story.tsx | 2 +- 21 files changed, 71 insertions(+), 57 deletions(-) diff --git a/lib/usagereporter/teleport/types_discover.go b/lib/usagereporter/teleport/types_discover.go index 5e617378c282c..3e170ef7c257d 100644 --- a/lib/usagereporter/teleport/types_discover.go +++ b/lib/usagereporter/teleport/types_discover.go @@ -181,8 +181,8 @@ func (u *UIDiscoverIntegrationAWSOIDCConnectEvent) Anonymize(a utils.Anonymizer) type UIDiscoverDatabaseRDSEnrollEvent prehogv1a.UIDiscoverDatabaseRDSEnrollEvent func (u *UIDiscoverDatabaseRDSEnrollEvent) CheckAndSetDefaults() error { - if u.SelectedResourcesCount <= 0 { - return trace.BadParameter("selected resources count must be 1 or more") + if u.SelectedResourcesCount < 0 { + return trace.BadParameter("selected resources count must be 0 or more") } return trace.Wrap(validateDiscoverBaseEventFields(u.Metadata, u.Resource, u.Status)) } diff --git a/web/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx b/web/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx index 785b15c9dbc11..e1d9a7a639fc4 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx @@ -56,7 +56,7 @@ export const PathInput = forwardRef< const StyledFieldInput = styled(FieldInput)` input { - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid ${props => props.theme.colors.text.muted}; background: transparent; color: white; box-shadow: none; diff --git a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx index b3dacaaf26c14..e4486c7524d1d 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx @@ -110,7 +110,7 @@ const Li = styled.li` const ProgressBackground = styled.div` border-radius: 50px; - background: rgba(255, 255, 255, 0.05); + background: ${props => props.theme.colors.spotBackground[0]}; width: 100%; `; diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx index c468e0e3460cd..9057c01c60451 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx @@ -160,7 +160,7 @@ export function CreateDatabaseView({ Labels make this new database discoverable by the database service.
- Not defining labels is equivalent to asteriks (any + Not defining labels is equivalent to asterisks (any database service can discover this database).
) : ( - + {attempt.status === 'processing' && ( @@ -73,24 +73,9 @@ export function IamPolicyView({ {attempt.status === 'success' && ( - Run this AWS CLI command to create a IAM policy.
- Then attach this policy to appropriate AWS resources (eg.{' '} - - IAM users - - ,{' '} - - ec2 instance - - ). + Run this AWS CLI command to create an IAM policy:
- + + + Then attach this policy to your AWS EC2 instance role. + + + See{' '} + + Attach policy to an IAM role + {' '} + and{' '} + + Attach an IAM role to an instance + + )}
diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx index 595f05612dc8f..31ee345a103bb 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx @@ -376,7 +376,7 @@ function DbEngineInstructions({ const StyledBox = styled(Box)` max-width: 800px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 20px; `; diff --git a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx index 17ae07ea8cbf4..bc2faa02957e9 100644 --- a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx @@ -168,7 +168,7 @@ export function TestConnectionView({ const StyledBox = styled(Box)` max-width: 800px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 20px; `; diff --git a/web/packages/teleport/src/Discover/Database/index.tsx b/web/packages/teleport/src/Discover/Database/index.tsx index fc8b494ddf463..0cd1b163dc2d3 100644 --- a/web/packages/teleport/src/Discover/Database/index.tsx +++ b/web/packages/teleport/src/Discover/Database/index.tsx @@ -32,6 +32,7 @@ import { TestConnection } from 'teleport/Discover/Database/TestConnection'; import { DiscoverEvent } from 'teleport/services/userEvent'; import { ConnectAwsAccount } from 'teleport/Discover/Database/ConnectAwsAccount'; import { EnrollRdsDatabase } from 'teleport/Discover/Database/EnrollRdsDatabase'; +import { IamPolicy } from 'teleport/Discover/Database/IamPolicy'; export const DatabaseResource: ResourceViewConfig = { kind: ResourceKind.Database, @@ -70,6 +71,11 @@ export const DatabaseResource: ResourceViewConfig = { component: DownloadScript, eventName: DiscoverEvent.DeployService, }, + { + title: 'Configure IAM Policy', + component: IamPolicy, + eventName: DiscoverEvent.DatabaseConfigureIAMPolicy, + }, ]; break; diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx index 2f5ac4699917a..dba38e2801101 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx @@ -453,7 +453,7 @@ const InstallHelmChart = ({ const StyledBox = styled(Box)` max-width: 1000px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; border: 2px solid #2f3659; diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx index 62252d8f6fcd6..7a21b92268d27 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx @@ -205,7 +205,7 @@ export function TestConnection({ const StyledBox = styled(Box)` max-width: 800px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 20px; `; diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx index 0c3e3b7e9b509..633cb950ae543 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx @@ -113,7 +113,7 @@ export function TestConnection({ const StyledBox = styled(Box)` max-width: 800px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 20px; `; diff --git a/web/packages/teleport/src/Discover/Shared/CommandBox.tsx b/web/packages/teleport/src/Discover/Shared/CommandBox.tsx index 579c027400abc..ccb486d46bd71 100644 --- a/web/packages/teleport/src/Discover/Shared/CommandBox.tsx +++ b/web/packages/teleport/src/Discover/Shared/CommandBox.tsx @@ -21,10 +21,9 @@ import { Box, Text } from 'design'; const Container = styled(Box)` max-width: 1000px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; - border: 2px solid #2f3659; `; interface CommandBoxProps { diff --git a/web/packages/teleport/src/Discover/Shared/HintBox.tsx b/web/packages/teleport/src/Discover/Shared/HintBox.tsx index f85ef76ff7f4c..ff2c66f1a0060 100644 --- a/web/packages/teleport/src/Discover/Shared/HintBox.tsx +++ b/web/packages/teleport/src/Discover/Shared/HintBox.tsx @@ -25,7 +25,7 @@ import { TextIcon } from 'teleport/Discover/Shared/Text'; const HintBoxContainer = styled(Box)` max-width: 1000px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; border: 2px solid ${props => props.theme.colors.warning.main}; ; @@ -33,17 +33,17 @@ const HintBoxContainer = styled(Box)` export const WaitingInfo = styled(Box)` max-width: 1000px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; - border: 2px solid #2f3659; + border: 2px solid ${props => props.theme.colors.text.muted}; display: flex; align-items: center; `; export const SuccessInfo = styled(Box)` max-width: 1000px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; border: 2px solid ${props => props.theme.colors.success}; diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx b/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx index 9e39afaebc757..84ae0a626d14c 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/SetupAccessWrapper.tsx @@ -146,7 +146,7 @@ export function SetupAccessWrapper({ const StyledBox = styled(Box)` max-width: 700px; - background-color: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 20px; `; diff --git a/web/packages/teleport/src/Discover/Shared/Step.tsx b/web/packages/teleport/src/Discover/Shared/Step.tsx index 0eec69bfc882e..a7abd157784c0 100644 --- a/web/packages/teleport/src/Discover/Shared/Step.tsx +++ b/web/packages/teleport/src/Discover/Shared/Step.tsx @@ -47,7 +47,7 @@ export function Step(props: StepProps) { } export const StepContainer = styled.div` - background: rgba(255, 255, 255, 0.05); + background: ${props => props.theme.colors.spotBackground[0]}; border-radius: 8px; padding: 16px; margin-bottom: 12px; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx index ae33d0a6ddcc2..72c5461057379 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx @@ -105,7 +105,9 @@ enum InstructionStep { export function AwsOidc() { const ctx = useTeleport(); - let clusterPublicUri = ctx.storeUser.state.cluster.publicURL; + let clusterPublicUri = getClusterPublicUri( + ctx.storeUser.state.cluster.publicURL + ); const [stage, setStage] = useState(Stage.Initial); const [showRestartAnimation, setShowRestartAnimation] = useState(false); @@ -359,3 +361,15 @@ function getStageConfig(stage: Stage) { }; } } + +function getClusterPublicUri(uri: string) { + const uriParts = uri.split(':'); + const port = uriParts.length > 1 ? uriParts[1] : ''; + + // Strip 443 ports from uri. + if (port === '443') { + return uriParts[0]; + } + + return uri; +} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/IAM/IAMCreateNewPolicy.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/IAM/IAMCreateNewPolicy.tsx index a5f30b90e3838..c30c05e64ecad 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/IAM/IAMCreateNewPolicy.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/IAM/IAMCreateNewPolicy.tsx @@ -108,10 +108,13 @@ export function IAMCreateNewPolicy(props: CommonIAMProps) { "Version": "2012-10-17", "Statement": [ { - "Effect": "Allow", - "Action": "rds:DescribeDBInstances", - "Resource": "*" - } + "Effect": "Allow", + "Action": [ + "rds:DescribeDBInstances", + "rds:DescribeDBClusters" + ], + "Resource": "*" + } ] }`; } diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx index df09c181d307a..1e0b4b08dba22 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx @@ -60,7 +60,7 @@ export function SecondStageInstructions(props: CommonInstructionsProps) { bash={false} lines={[ { - text: `https://${getClusterPublicUri(props.clusterPublicUri)}`, + text: `https://${props.clusterPublicUri}`, }, ]} /> @@ -111,15 +111,3 @@ export function SecondStageInstructions(props: CommonInstructionsProps) {
); } - -function getClusterPublicUri(uri: string) { - const uriParts = uri.split(':'); - const port = uriParts.length > 1 ? uriParts[1] : ''; - - // Strip 443 ports from uri. - if (port === '443') { - return uriParts[0]; - } - - return uri; -} diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx index 982631672e082..6bb5379e568ab 100644 --- a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx +++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx @@ -51,7 +51,7 @@ const ActiveValue = styled.div` cursor: pointer; &:focus { - background: rgba(255, 255, 255, 0.05); + background: ${props => props.theme.colors.spotBackground[0]}; } `; diff --git a/web/packages/teleport/src/components/ToolTipNoPermBadge/ToolTipNoPermBadge.story.tsx b/web/packages/teleport/src/components/ToolTipNoPermBadge/ToolTipNoPermBadge.story.tsx index 700d4ee47da55..60936ca49d02e 100644 --- a/web/packages/teleport/src/components/ToolTipNoPermBadge/ToolTipNoPermBadge.story.tsx +++ b/web/packages/teleport/src/components/ToolTipNoPermBadge/ToolTipNoPermBadge.story.tsx @@ -47,5 +47,5 @@ const SomeBox = styled.div` display: flex; position: relative; align-items: center; - background: rgba(255, 255, 255, 0.05); + background-color: ${props => props.theme.colors.spotBackground[0]}; `; From 86a6dddfd265b7ea9949efa3350ab40b765025b9 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Wed, 3 May 2023 16:33:40 -0700 Subject: [PATCH 5/6] Remove unused get url --- web/packages/teleport/src/config.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 0d6cf8f46da9b..395c0a919d4f4 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -623,17 +623,8 @@ const cfg = { }); }, - getIntegrationExecuteUrl(params: UrlIntegrationExecuteRequestParams) { - // Currently you can only create integrations at the root cluster. - const clusterId = cfg.proxyCluster; - - return generatePath(cfg.api.integrationExecutePath, { - clusterId, - ...params, - }); - }, - getAwsRdsDbListUrl(integrationName: string) { + // Currently you can only create integrations at the root cluster. const clusterId = cfg.proxyCluster; return generatePath(cfg.api.awsRdsDbListPath, { @@ -730,12 +721,4 @@ export interface UrlResourcesParams { searchAsRoles?: 'yes' | ''; } -export interface UrlIntegrationExecuteRequestParams { - // name is the name of integration to execute (use). - name: string; - // action is the expected backend string value - // used to describe what to use the integration for. - action: 'aws-oidc/list_databases'; -} - export default cfg; From 7497618dd28a44c79d2a1ef7c100672a18f2b40b Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Fri, 5 May 2023 17:58:22 -0700 Subject: [PATCH 6/6] Update snapshot --- .../SelectResource.story.test.tsx.snap | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap index cad9306d055dc..7a7176c04f3ef 100644 --- a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap @@ -1274,7 +1274,7 @@ exports[`render with all access 1`] = ` >
Amazon Web Services (AWS) @@ -1315,7 +1315,7 @@ exports[`render with all access 1`] = ` >
Amazon Web Services (AWS) @@ -2967,7 +2967,6 @@ exports[`render with no access 1`] = ` >
Lacking Permissions
@@ -2990,7 +2989,7 @@ exports[`render with no access 1`] = ` >
Amazon Web Services (AWS) @@ -3031,7 +3030,7 @@ exports[`render with no access 1`] = ` >
Amazon Web Services (AWS) @@ -3050,7 +3049,6 @@ exports[`render with no access 1`] = ` >
Lacking Permissions
@@ -4805,7 +4803,6 @@ exports[`render with partial access 1`] = ` >
Lacking Permissions
@@ -4828,7 +4825,7 @@ exports[`render with partial access 1`] = ` >
Amazon Web Services (AWS) @@ -4869,7 +4866,7 @@ exports[`render with partial access 1`] = ` >
Amazon Web Services (AWS) @@ -4888,7 +4885,6 @@ exports[`render with partial access 1`] = ` >
Lacking Permissions