From d6d6299ee316cea2b4f42149090557e43349a62b Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Wed, 15 Jan 2025 14:46:04 -0800 Subject: [PATCH] [v17] WebDiscover: supports defining labels for Discover flows with single resource enroll (#51038) * WebDiscover: allow setting resource labels when enrolling single eks, rds, server, kube (#50606) * WebDiscover: Allow setting labels when enrolling single web application (#50853) * Allow labels for generic add web app flow * Update test * WebDiscover: allow setting labels when enrolling aws app (#50976) * WebDiscover: allow setting labels when enrolling aws app * Address CRs * Web: Fix v1 fallback with v2 endpoints (#51058) * Implement a fallback hook for re-use * Split v1 and v2 endpoints into separate funcs * Provide fallback for create app access * Provide fallback for join token suspender * Provide fallback for eks * Provide fallback for app * Address CRs --- .../teleport/src/Apps/AddApp/AddApp.story.tsx | 48 +++- .../teleport/src/Apps/AddApp/AddApp.tsx | 4 + .../src/Apps/AddApp/Automatically.test.tsx | 23 +- .../src/Apps/AddApp/Automatically.tsx | 70 +++--- .../src/Apps/AddApp/useAddApp.test.tsx | 95 ++++++++ .../teleport/src/Apps/AddApp/useAddApp.ts | 44 +++- .../CreateAppAccess/CreateAppAccess.story.tsx | 9 +- .../CreateAppAccess/CreateAppAccess.test.tsx | 24 +- .../CreateAppAccess/CreateAppAccess.tsx | 173 +++++++++----- .../CreateDatabase/CreateDatabase.tsx | 15 +- .../ManualDeploy/ManualDeploy.tsx | 12 +- .../EnrollRdsDatabase/SingleEnrollment.tsx | 87 +++++-- .../Database/MutualTls/useMutualTls.ts | 6 +- .../EnrollEKSCluster/Dialogs.story.tsx | 2 +- .../EnrollEKSCluster.test.tsx | 58 ++++- .../EnrollEKSCluster/EnrollEksCluster.tsx | 166 ++++++++++---- .../EnrollEKSCluster/ManualHelmDialog.tsx | 21 +- .../HelmChart/HelmChart.story.tsx | 0 .../HelmChart/HelmChart.test.tsx | 0 .../{ => SelfHosted}/HelmChart/HelmChart.tsx | 136 ++++++++--- .../{ => SelfHosted}/HelmChart/index.ts | 4 +- .../Discover/Kubernetes/SelfHosted/index.ts | 19 ++ .../src/Discover/Kubernetes/index.tsx | 2 +- .../DownloadScript/DownloadScript.story.tsx | 10 +- .../Server/DownloadScript/DownloadScript.tsx | 215 +++++++++++++----- .../Shared/LabelsCreater/LabelsCreater.tsx | 3 +- .../Discover/Shared/PingTeleportContext.tsx | 4 + .../ResourceLabelTooltip.tsx | 26 ++- .../Shared/useJoinTokenSuspender.test.tsx | 155 +++++++++++++ .../Discover/Shared/useJoinTokenSuspender.ts | 66 ++++-- web/packages/teleport/src/config.ts | 19 +- .../integrations/integrations.test.ts | 44 ---- .../src/services/integrations/integrations.ts | 44 +++- .../src/services/integrations/types.ts | 13 ++ .../src/services/joinToken/joinToken.test.ts | 30 +-- .../src/services/joinToken/joinToken.ts | 42 ++-- .../src/services/version/unsupported.test.ts | 79 +++++++ .../src/services/version/unsupported.ts | 108 ++++++++- 38 files changed, 1449 insertions(+), 427 deletions(-) create mode 100644 web/packages/teleport/src/Apps/AddApp/useAddApp.test.tsx rename web/packages/teleport/src/Discover/Kubernetes/{ => SelfHosted}/HelmChart/HelmChart.story.tsx (100%) rename web/packages/teleport/src/Discover/Kubernetes/{ => SelfHosted}/HelmChart/HelmChart.test.tsx (100%) rename web/packages/teleport/src/Discover/Kubernetes/{ => SelfHosted}/HelmChart/HelmChart.tsx (80%) rename web/packages/teleport/src/Discover/Kubernetes/{ => SelfHosted}/HelmChart/index.ts (89%) create mode 100644 web/packages/teleport/src/Discover/Kubernetes/SelfHosted/index.ts create mode 100644 web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.test.tsx create mode 100644 web/packages/teleport/src/services/version/unsupported.test.ts diff --git a/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx b/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx index db9ba0c4007ba..4ae3007934307 100644 --- a/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx +++ b/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx @@ -16,18 +16,50 @@ * along with this program. If not, see . */ +import { useState } from 'react'; + +import { JoinToken } from 'teleport/services/joinToken'; + import { AddApp } from './AddApp'; export default { - title: 'Teleport/Apps/Add', + title: 'Teleport/Discover/Application/Web', }; -export const Created = () => ( - -); +export const CreatedWithoutLabels = () => { + const [token, setToken] = useState(); + + return ( + { + setToken(props.token); + return Promise.resolve(true); + }} + /> + ); +}; + +export const CreatedWithLabels = () => { + const [token, setToken] = useState(); -export const Loaded = () => { - return ; + return ( + { + setToken(props.token); + return Promise.resolve(true); + }} + /> + ); }; export const Processing = () => ( @@ -72,8 +104,10 @@ const props = { createJoinToken: () => Promise.resolve(null), version: '5.0.0-dev', reset: () => null, + labels: [], + setLabels: () => null, attempt: { - status: '', + status: 'success', statusText: '', } as any, token: { diff --git a/web/packages/teleport/src/Apps/AddApp/AddApp.tsx b/web/packages/teleport/src/Apps/AddApp/AddApp.tsx index b40735fbce53d..7a82293d33a7a 100644 --- a/web/packages/teleport/src/Apps/AddApp/AddApp.tsx +++ b/web/packages/teleport/src/Apps/AddApp/AddApp.tsx @@ -44,6 +44,8 @@ export function AddApp({ setAutomatic, isAuthTypeLocal, token, + labels, + setLabels, }: State & Props) { return ( )} {!automatic && ( diff --git a/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx b/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx index 5761abdbcb42f..ece5ce843aa57 100644 --- a/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx +++ b/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import { act } from '@testing-library/react'; - import { fireEvent, render, screen } from 'design/utils/testing'; import { Automatically, createAppBashCommand } from './Automatically'; @@ -33,12 +31,14 @@ test('render command only after form submit', async () => { roles: [], content: '', }; - render( + const { rerender } = render( {}} onCreate={() => Promise.resolve(true)} + labels={[]} + setLabels={() => null} + token={null} /> ); @@ -56,8 +56,21 @@ test('render command only after form submit', async () => { target: { value: 'https://gravitational.com' }, }); + rerender( + {}} + onCreate={() => Promise.resolve(true)} + labels={[]} + setLabels={() => null} + token={token} + /> + ); + // click button - act(() => screen.getByRole('button', { name: /Generate Script/i }).click()); + fireEvent.click(screen.getByRole('button', { name: /Generate Script/i })); + + await screen.findByText(/Regenerate Script/i); // after form submission should show the command cmd = createAppBashCommand(token.id, 'app-name', 'https://gravitational.com'); diff --git a/web/packages/teleport/src/Apps/AddApp/Automatically.tsx b/web/packages/teleport/src/Apps/AddApp/Automatically.tsx index de6669284f1ce..6e49916ef1261 100644 --- a/web/packages/teleport/src/Apps/AddApp/Automatically.tsx +++ b/web/packages/teleport/src/Apps/AddApp/Automatically.tsx @@ -20,6 +20,7 @@ import { KeyboardEvent, useEffect, useState } from 'react'; import { Alert, + Box, ButtonPrimary, ButtonSecondary, Flex, @@ -33,24 +34,27 @@ import { Attempt } from 'shared/hooks/useAttemptNext'; import TextSelectCopy from 'teleport/components/TextSelectCopy'; import cfg from 'teleport/config'; +import { LabelsCreater } from 'teleport/Discover/Shared'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; +import { ResourceLabel } from 'teleport/services/agents'; import { State } from './useAddApp'; export function Automatically(props: Props) { - const { onClose, attempt, token } = props; + const { onClose, attempt, token, labels, setLabels } = props; const [name, setName] = useState(''); const [uri, setUri] = useState(''); const [cmd, setCmd] = useState(''); useEffect(() => { - if (name && uri) { + if (name && uri && token) { const cmd = createAppBashCommand(token.id, name, uri); setCmd(cmd); } }, [token]); - function handleRegenerate(validator: Validator) { + function onGenerateScript(validator: Validator) { if (!validator.validate()) { return; } @@ -58,25 +62,12 @@ export function Automatically(props: Props) { props.onCreate(name, uri); } - function handleGenerate(validator: Validator) { - if (!validator.validate()) { - return; - } - - const cmd = createAppBashCommand(token.id, name, uri); - setCmd(cmd); - } - function handleEnterPress( e: KeyboardEvent, validator: Validator ) { if (e.key === 'Enter') { - if (cmd) { - handleRegenerate(validator); - } else { - handleGenerate(validator); - } + onGenerateScript(validator); } } @@ -96,6 +87,7 @@ export function Automatically(props: Props) { mr="3" onKeyPress={e => handleEnterPress(e, validator)} onChange={e => setName(e.target.value.toLowerCase())} + disabled={attempt.status === 'processing'} /> handleEnterPress(e, validator)} onChange={e => setUri(e.target.value)} + disabled={attempt.status === 'processing'} /> + + + Add Labels (Optional) + + + + {!cmd && ( Teleport can automatically set up application access. Provide @@ -136,24 +145,13 @@ export function Automatically(props: Props) { )} - {!cmd && ( - handleGenerate(validator)} - > - Generate Script - - )} - {cmd && ( - handleRegenerate(validator)} - > - Regenerate - - )} + onGenerateScript(validator)} + > + {cmd ? 'Regenerate Script' : 'Generate Script'} + ; token: State['token']; attempt: Attempt; + labels: ResourceLabel[]; + setLabels(r: ResourceLabel[]): void; }; diff --git a/web/packages/teleport/src/Apps/AddApp/useAddApp.test.tsx b/web/packages/teleport/src/Apps/AddApp/useAddApp.test.tsx new file mode 100644 index 0000000000000..0cb1e21428531 --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/useAddApp.test.tsx @@ -0,0 +1,95 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook, waitFor } from '@testing-library/react'; + +import { ContextProvider } from 'teleport/index'; +import { userContext } from 'teleport/Main/fixtures'; +import { ProxyRequiresUpgrade } from 'teleport/services/version/unsupported'; +import TeleportContext from 'teleport/teleportContext'; + +import useAddApp from './useAddApp'; + +const ctx = new TeleportContext(); + +beforeEach(() => { + ctx.storeUser.setState({ ...userContext }); + jest + .spyOn(ctx.joinTokenService, 'fetchJoinToken') + .mockResolvedValue(tokenResp); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +test('create token without labels', async () => { + jest + .spyOn(ctx.joinTokenService, 'fetchJoinTokenV2') + .mockResolvedValue(tokenResp); + + const wrapper = ({ children }) => ( + {children} + ); + + let { result } = renderHook(() => useAddApp(ctx), { wrapper }); + + await waitFor(() => { + expect(result.current.token).not.toBeUndefined(); + }); + + expect(ctx.joinTokenService.fetchJoinTokenV2).toHaveBeenCalledTimes(1); + expect(ctx.joinTokenService.fetchJoinToken).not.toHaveBeenCalled(); + expect(result.current.token).toEqual(tokenResp); +}); + +test('create token without labels with v1 fallback', async () => { + jest + .spyOn(ctx.joinTokenService, 'fetchJoinTokenV2') + .mockRejectedValueOnce(new Error(ProxyRequiresUpgrade)); + + const wrapper = ({ children }) => ( + {children} + ); + + let { result } = renderHook(() => useAddApp(ctx), { wrapper }); + + await waitFor(() => { + expect(result.current.token).not.toBeUndefined(); + }); + + expect(ctx.joinTokenService.fetchJoinTokenV2).toHaveBeenCalledTimes(1); + expect(ctx.joinTokenService.fetchJoinToken).toHaveBeenCalledTimes(1); + expect(result.current.token).toEqual(tokenResp); +}); + +const tokenResp = { + allow: undefined, + bot_name: undefined, + content: undefined, + expiry: null, + expiryText: '', + gcp: undefined, + id: undefined, + isStatic: undefined, + method: undefined, + internalResourceId: 'abc', + roles: ['Application'], + safeName: undefined, + suggestedLabels: [], +}; diff --git a/web/packages/teleport/src/Apps/AddApp/useAddApp.ts b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts index be04b6cba17fd..4774f24355618 100644 --- a/web/packages/teleport/src/Apps/AddApp/useAddApp.ts +++ b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts @@ -20,7 +20,9 @@ import { useEffect, useState } from 'react'; import useAttempt from 'shared/hooks/useAttemptNext'; -import type { JoinToken } from 'teleport/services/joinToken'; +import { ResourceLabel } from 'teleport/services/agents'; +import type { JoinToken, JoinTokenRequest } from 'teleport/services/joinToken'; +import { useV1Fallback } from 'teleport/services/version/unsupported'; import TeleportContext from 'teleport/teleportContext'; export default function useAddApp(ctx: TeleportContext) { @@ -31,15 +33,43 @@ export default function useAddApp(ctx: TeleportContext) { const isEnterprise = ctx.isEnterprise; const [automatic, setAutomatic] = useState(isEnterprise); const [token, setToken] = useState(); + const [labels, setLabels] = useState([]); + + // TODO(kimlisa): DELETE IN 19.0 + const { tryV1Fallback } = useV1Fallback(); useEffect(() => { - createToken(); - }, []); + // We don't want to create token on first render + // which defaults to the automatic tab because + // user may want to add labels. + if (!automatic) { + setLabels([]); + // When switching to manual tab, token can be re-used + // if token was already generated from automatic tab. + if (!token) { + createToken(); + } + } + }, [automatic]); + + async function fetchJoinToken() { + const req: JoinTokenRequest = { roles: ['App'], suggestedLabels: labels }; + let resp: JoinToken; + try { + resp = await ctx.joinTokenService.fetchJoinTokenV2(req); + } catch (err) { + resp = await tryV1Fallback({ + kind: 'create-join-token', + err, + req, + ctx, + }); + } + return resp; + } function createToken() { - return run(() => - ctx.joinTokenService.fetchJoinToken({ roles: ['App'] }).then(setToken) - ); + return run(() => fetchJoinToken().then(setToken)); } return { @@ -52,6 +82,8 @@ export default function useAddApp(ctx: TeleportContext) { isAuthTypeLocal, isEnterprise, token, + labels, + setLabels, }; } diff --git a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.story.tsx b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.story.tsx index b875b4ee6f251..32fd381b83658 100644 --- a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.story.tsx +++ b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.story.tsx @@ -45,7 +45,7 @@ export const Success = () => ; Success.parameters = { msw: { handlers: [ - http.post(cfg.api.awsAppAccessPath, () => + http.post(cfg.api.awsAppAccess.createV2, () => HttpResponse.json({ name: 'app-1' }) ), ], @@ -59,7 +59,10 @@ export const Loading = () => { Loading.parameters = { msw: { handlers: [ - http.post(cfg.api.awsAppAccessPath, async () => await delay('infinite')), + http.post( + cfg.api.awsAppAccess.createV2, + async () => await delay('infinite') + ), ], }, }; @@ -68,7 +71,7 @@ export const Failed = () => ; Failed.parameters = { msw: { handlers: [ - http.post(cfg.api.awsAppAccessPath, () => + http.post(cfg.api.awsAppAccess.createV2, () => HttpResponse.json( { message: 'Some kind of error message', diff --git a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.test.tsx b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.test.tsx index a1ac5d1b032b7..89a5ef1abcd3d 100644 --- a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.test.tsx +++ b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.test.tsx @@ -39,12 +39,13 @@ import { DiscoverEventResource, userEventService, } from 'teleport/services/userEvent'; +import { ProxyRequiresUpgrade } from 'teleport/services/version/unsupported'; import TeleportContext from 'teleport/teleportContext'; import { CreateAppAccess } from './CreateAppAccess'; beforeEach(() => { - jest.spyOn(integrationService, 'createAwsAppAccess').mockResolvedValue(app); + jest.spyOn(integrationService, 'createAwsAppAccessV2').mockResolvedValue(app); jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(undefined as never); @@ -55,6 +56,25 @@ afterEach(() => { }); test('create app access', async () => { + jest.spyOn(integrationService, 'createAwsAppAccess').mockResolvedValue(app); + + const { ctx, discoverCtx } = getMockedContexts(); + + renderCreateAppAccess(ctx, discoverCtx); + await screen.findByText(/bash/i); + + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await screen.findByText(/aws-console/i); + expect(integrationService.createAwsAppAccessV2).toHaveBeenCalledTimes(1); + expect(integrationService.createAwsAppAccess).not.toHaveBeenCalled(); +}); + +test('create app access with v1 endpoint auto retry', async () => { + jest + .spyOn(integrationService, 'createAwsAppAccessV2') + .mockRejectedValueOnce(new Error(ProxyRequiresUpgrade)); + jest.spyOn(integrationService, 'createAwsAppAccess').mockResolvedValue(app); + const { ctx, discoverCtx } = getMockedContexts(); renderCreateAppAccess(ctx, discoverCtx); @@ -62,6 +82,8 @@ test('create app access', async () => { await userEvent.click(screen.getByRole('button', { name: /next/i })); await screen.findByText(/aws-console/i); + + expect(integrationService.createAwsAppAccessV2).toHaveBeenCalledTimes(1); expect(integrationService.createAwsAppAccess).toHaveBeenCalledTimes(1); }); diff --git a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx index d3341bce3d419..18391957d0a07 100644 --- a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx +++ b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx @@ -16,23 +16,29 @@ * along with this program. If not, see . */ +import { useState } from 'react'; import styled from 'styled-components'; import { Box, Flex, H3, Link, Mark } from 'design'; import { Danger } from 'design/Alert'; -import { P } from 'design/Text/Text'; +import { P, Subtitle3 } from 'design/Text/Text'; import { IconTooltip } from 'design/Tooltip'; import TextEditor from 'shared/components/TextEditor'; +import Validation, { Validator } from 'shared/components/Validation'; import { useAsync } from 'shared/hooks/useAsync'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; import cfg from 'teleport/config'; import { Container } from 'teleport/Discover/Shared/CommandBox'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; import { useDiscover } from 'teleport/Discover/useDiscover'; +import { ResourceLabel } from 'teleport/services/agents'; +import { App } from 'teleport/services/apps/types'; import { integrationService } from 'teleport/services/integrations'; import { splitAwsIamArn } from 'teleport/services/integrations/aws'; +import { useV1Fallback } from 'teleport/services/version/unsupported'; -import { ActionButtons, Header } from '../../Shared'; +import { ActionButtons, Header, LabelsCreater } from '../../Shared'; import { AppCreatedDialog } from './AppCreatedDialog'; const IAM_POLICY_NAME = 'AWSAppAccess'; @@ -41,12 +47,32 @@ export function CreateAppAccess() { const { agentMeta, updateAgentMeta, emitErrorEvent, nextStep } = useDiscover(); const { awsIntegration } = agentMeta; + const [labels, setLabels] = useState([]); + + // TODO(kimlisa): DELETE IN 19.0 + const { tryV1Fallback } = useV1Fallback(); const [attempt, createApp] = useAsync(async () => { + const labelsMap: Record = {}; + labels.forEach(l => (labelsMap[l.name] = l.value)); try { - const app = await integrationService.createAwsAppAccess( - awsIntegration.name - ); + const req = { labels: labelsMap }; + + let app: App; + try { + app = await integrationService.createAwsAppAccessV2( + awsIntegration.name, + req + ); + } catch (err) { + app = await tryV1Fallback({ + kind: 'create-app-access', + err, + req, + integrationName: awsIntegration.name, + }); + } + updateAgentMeta({ ...agentMeta, app, @@ -58,6 +84,13 @@ export function CreateAppAccess() { } }); + function onCreateApp(validator: Validator) { + if (!validator.validate()) { + return; + } + createApp(); + } + const { awsAccountId: accountID, arnResourceName: iamRoleName } = splitAwsIamArn(agentMeta.awsIntegration.spec.roleArn); const scriptUrl = cfg.getAwsIamConfigureScriptAppAccessUrl({ @@ -66,62 +99,82 @@ export function CreateAppAccess() { }); return ( - -
Enable Access to AWS with Teleport Application Access
-

- An application will be created that will use the selected AWS OIDC - Integration {agentMeta.awsIntegration.name} for proxying - access to AWS Management Console, AWS CLI, and AWS APIs. -

- {attempt.status === 'error' && ( - {attempt.statusText} - )} - - -

First configure your AWS IAM permissions

- - The following IAM permissions will be added as an inline policy - named {IAM_POLICY_NAME} to IAM role{' '} - {iamRoleName} - - - - - - -
-

- Run the command below on your{' '} - - AWS CloudShell - {' '} - to configure your IAM permissions. -

- -
- - - {attempt.status === 'success' && ( - + + {({ validator }) => ( + +
Enable Access to AWS with Teleport Application Access
+

+ An application will be created that will use the selected AWS OIDC + Integration {agentMeta.awsIntegration.name} for + proxying access to AWS Management Console, AWS CLI, and AWS APIs. +

+ {attempt.status === 'error' && ( + {attempt.statusText} + )} + +

Step 1

+ + Configure your AWS IAM permissions + + The following IAM permissions will be added as an inline policy + named {IAM_POLICY_NAME} to IAM role{' '} + {iamRoleName} + + + + + + + +

+ Run the command below on your{' '} + + AWS CloudShell + {' '} + to configure your IAM permissions. +

+ +
+ + +

Step 2 (Optional)

+ + Add Labels + + + +
+ + onCreateApp(validator)} + disableProceed={ + attempt.status === 'processing' || attempt.status === 'success' + } + /> + {attempt.status === 'success' && ( + + )} +
)} -
+ ); } diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx index c741944ebf6bc..79bceda4fcae4 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/CreateDatabase.tsx @@ -25,6 +25,7 @@ import TextEditor from 'shared/components/TextEditor'; import Validation, { Validator } from 'shared/components/Validation'; import { requiredField } from 'shared/components/Validation/rules'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; import type { ResourceLabel } from 'teleport/services/agents'; import { @@ -162,13 +163,13 @@ export function CreateDatabaseView({ /> - Labels (optional) - - Labels make this new database discoverable by the database - service.
- Not defining labels is equivalent to asterisks (any - database service can discover this database). -
+ + Labels (optional) + + (agentMeta.resourceName); const showHint = useShowHint(active); + useEffect(() => { + return () => clearCachedJoinTokenResult([ResourceKind.Database]); + }, []); + function handleNextStep() { updateAgentMeta({ ...agentMeta, diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx index eba8893130f3e..d60f8a992535a 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/SingleEnrollment.tsx @@ -18,13 +18,16 @@ import { useEffect, useState } from 'react'; -import { Text } from 'design'; +import { Flex, Subtitle1, Text } from 'design'; import { FetchStatus } from 'design/DataTable/types'; +import Validation, { Validator } from 'shared/components/Validation'; import { Attempt } from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; import { getRdsEngineIdentifier } from 'teleport/Discover/SelectResource/types'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; import { useDiscover } from 'teleport/Discover/useDiscover'; +import { ResourceLabel } from 'teleport/services/agents'; import { Database } from 'teleport/services/databases'; import { AwsRdsDatabase, @@ -33,7 +36,7 @@ import { Vpc, } from 'teleport/services/integrations'; -import { ActionButtons } from '../../Shared'; +import { ActionButtons, LabelsCreater } from '../../Shared'; import { CreateDatabaseDialog } from '../CreateDatabase/CreateDatabaseDialog'; import { useCreateDatabase } from '../CreateDatabase/useCreateDatabase'; import { DatabaseList } from './RdsDatabaseList'; @@ -90,6 +93,7 @@ export function SingleEnrollment({ const [tableData, setTableData] = useState(); const [selectedDb, setSelectedDb] = useState(); + const [customLabels, setCustomLabels] = useState([]); useEffect(() => { if (vpc) { @@ -98,6 +102,12 @@ export function SingleEnrollment({ } }, [vpc]); + function onSelectRds(rds: CheckedAwsRdsDatabase) { + // when changing selected db, clear defined labels + setCustomLabels([]); + setSelectedDb(rds); + } + function fetchNextPage() { fetchRdsDatabases({ ...tableData }, vpc); } @@ -175,6 +185,17 @@ export function SingleEnrollment({ } } + function handleOnProceedWithValidation( + validator: Validator, + { overwriteDb = false } = {} + ) { + if (!validator.validate()) { + return; + } + + handleOnProceed({ overwriteDb }); + } + function handleOnProceed({ overwriteDb = false } = {}) { // Corner case where if registering db fails a user can: // 1) change region, which will list new databases or @@ -185,7 +206,9 @@ export function SingleEnrollment({ name: selectedDb.name, protocol: selectedDb.engine, uri: selectedDb.uri, - labels: selectedDb.labels, + // The labels from the `selectedDb` are AWS tags which + // will be imported as is. + labels: [...selectedDb.labels, ...customLabels], awsRds: selectedDb, awsRegion: region, awsVpcId: vpc.id, @@ -198,23 +221,47 @@ export function SingleEnrollment({ return ( <> - {showTable && ( - <> - Select an RDS database to enroll: - - - )} - + + {({ validator }) => ( + <> + {showTable && ( + <> + Select an RDS database to enroll: + + {selectedDb && ( + <> + + Optionally Add More Labels + + + + + )} + + )} + handleOnProceedWithValidation(validator)} + disableProceed={disableBtns || !showTable || !selectedDb} + /> + + )} + {attempt.status !== '' && ( { jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(undefined as never); + jest + .spyOn(auth, 'getMfaChallengeResponseForAdminAction') + .mockResolvedValue(undefined); createDiscoveryConfig = jest .spyOn(discoveryService, 'createDiscoveryConfig') .mockResolvedValue({ @@ -57,7 +62,7 @@ describe('test EnrollEksCluster.tsx', () => { afterEach(() => { cfg.isCloud = defaultIsCloud; - jest.restoreAllMocks(); + jest.resetAllMocks(); }); test('without EKS clusters available, does not attempt to fetch kube clusters', async () => { @@ -104,7 +109,7 @@ describe('test EnrollEksCluster.tsx', () => { jest.spyOn(integrationService, 'fetchEksClusters').mockResolvedValue({ clusters: mockEKSClusters, }); - jest.spyOn(integrationService, 'enrollEksClusters'); + jest.spyOn(integrationService, 'enrollEksClustersV2'); render(); @@ -136,7 +141,7 @@ describe('test EnrollEksCluster.tsx', () => { DISCOVERY_GROUP_CLOUD ); - expect(integrationService.enrollEksClusters).not.toHaveBeenCalled(); + expect(integrationService.enrollEksClustersV2).not.toHaveBeenCalled(); }); test('auto enroll (self-hosted) is on by default', async () => { @@ -144,7 +149,7 @@ describe('test EnrollEksCluster.tsx', () => { jest.spyOn(integrationService, 'fetchEksClusters').mockResolvedValue({ clusters: mockEKSClusters, }); - jest.spyOn(integrationService, 'enrollEksClusters'); + jest.spyOn(integrationService, 'enrollEksClustersV2'); render(); @@ -177,13 +182,19 @@ describe('test EnrollEksCluster.tsx', () => { DEFAULT_DISCOVERY_GROUP_NON_CLOUD ); - expect(integrationService.enrollEksClusters).not.toHaveBeenCalled(); + expect(integrationService.enrollEksClustersV2).not.toHaveBeenCalled(); }); - test('auto enroll disabled, enrolls cluster', async () => { + + test('auto enroll disabled, enrolls cluster without labels', async () => { jest.spyOn(integrationService, 'fetchEksClusters').mockResolvedValue({ clusters: mockEKSClusters, }); - jest.spyOn(integrationService, 'enrollEksClusters'); + jest + .spyOn(integrationService, 'enrollEksClustersV2') + .mockResolvedValue({} as any); // value doesn't matter + jest + .spyOn(integrationService, 'enrollEksClusters') + .mockResolvedValue({} as any); // value doesn't matter render(); @@ -199,8 +210,41 @@ describe('test EnrollEksCluster.tsx', () => { act(() => screen.getByText('Enroll EKS Cluster').click()); + await screen.findByTestId('dialogbox'); + expect(discoveryService.createDiscoveryConfig).not.toHaveBeenCalled(); expect(KubeService.prototype.fetchKubernetes).toHaveBeenCalledTimes(1); + expect(integrationService.enrollEksClustersV2).toHaveBeenCalledTimes(1); + expect(integrationService.enrollEksClusters).not.toHaveBeenCalled(); + }); + + test('enroll eks without labels with v1 fallback', async () => { + jest.spyOn(integrationService, 'fetchEksClusters').mockResolvedValue({ + clusters: mockEKSClusters, + }); + jest + .spyOn(integrationService, 'enrollEksClustersV2') + .mockRejectedValueOnce(new Error(ProxyRequiresUpgrade)); + jest.spyOn(integrationService, 'enrollEksClusters'); + + render(); + + // select a region from selector. + const selectEl = screen.getByLabelText(/aws region/i); + fireEvent.focus(selectEl); + fireEvent.keyDown(selectEl, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.click(screen.getByText('us-east-2')); + + await screen.findByText(/eks1/i); + + act(() => screen.getByRole('radio').click()); + act(() => screen.getByText('Enroll EKS Cluster').click()); + + expect(integrationService.enrollEksClustersV2).toHaveBeenCalledTimes(1); + + await screen.findByTestId('dialogbox'); + + expect(integrationService.enrollEksClustersV2).toHaveBeenCalledTimes(1); expect(integrationService.enrollEksClusters).toHaveBeenCalledTimes(1); }); }); diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx index 6de7f1aa88c2e..efdad9aacad5b 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx @@ -25,17 +25,19 @@ import { ButtonText, Flex, Link, + Subtitle1, Text, Toggle, } from 'design'; import { Danger } from 'design/Alert'; import { FetchStatus } from 'design/DataTable/types'; import { IconTooltip } from 'design/Tooltip'; +import Validation, { Validator } from 'shared/components/Validation'; import useAttempt from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; import cfg from 'teleport/config'; -import { generateCmd } from 'teleport/Discover/Kubernetes/HelmChart/HelmChart'; +import { generateCmd } from 'teleport/Discover/Kubernetes/SelfHosted'; import { ConfigureIamPerms } from 'teleport/Discover/Shared/Aws/ConfigureIamPerms'; import { isIamPermError } from 'teleport/Discover/Shared/Aws/error'; import { AwsRegionSelector } from 'teleport/Discover/Shared/AwsRegionSelector'; @@ -43,8 +45,10 @@ import { ConfigureDiscoveryServiceDirections, CreatedDiscoveryConfigDialog, } from 'teleport/Discover/Shared/ConfigureDiscoveryService'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; import { AgentStepProps } from 'teleport/Discover/types'; import { EksMeta, useDiscover } from 'teleport/Discover/useDiscover'; +import { ResourceLabel } from 'teleport/services/agents'; import { createDiscoveryConfig, DEFAULT_DISCOVERY_GROUP_NON_CLOUD, @@ -53,6 +57,7 @@ import { } from 'teleport/services/discovery'; import { AwsEksCluster, + EnrollEksClustersResponse, integrationService, Regions, } from 'teleport/services/integrations'; @@ -62,9 +67,10 @@ import { DiscoverEvent, DiscoverEventStatus, } from 'teleport/services/userEvent'; +import { useV1Fallback } from 'teleport/services/version/unsupported'; import useTeleport from 'teleport/useTeleport'; -import { ActionButtons, Header } from '../../Shared'; +import { ActionButtons, Header, LabelsCreater } from '../../Shared'; import { AgentWaitingDialog } from './AgentWaitingDialog'; import { ClustersList } from './EksClustersList'; import { EnrollmentDialog } from './EnrollmentDialog'; @@ -134,9 +140,24 @@ export function EnrollEksCluster(props: AgentStepProps) { // join token will be set only if user opens ManualHelmDialog, // we delay it to avoid premature admin action MFA confirmation request. const [joinToken, setJoinToken] = useState(null); + const [customLabels, setCustomLabels] = useState([]); + + // TODO(kimlisa): DELETE IN 19.0 + const { tryV1Fallback } = useV1Fallback(); const ctx = useTeleport(); + function onSelectCluster(eks: CheckedEksCluster) { + // when changing selected cluster, clear defined labels + setCustomLabels([]); + setSelectedCluster(eks); + } + + function clearSelectedCluster() { + setSelectedCluster(null); + setCustomLabels([]); + } + function fetchClustersWithNewRegion(region: Regions) { setSelectedRegion(region); // Clear table when fetching with new region. @@ -148,7 +169,7 @@ export function EnrollEksCluster(props: AgentStepProps) { } function refreshClustersList() { - setSelectedCluster(null); + clearSelectedCluster(); // When refreshing, start the table back at page 1. fetchClusters({ ...tableData, startKey: '', items: [] }); } @@ -214,9 +235,7 @@ export function EnrollEksCluster(props: AgentStepProps) { if (tableData.items.length > 0) { setTableData(emptyTableData); } - if (selectedCluster) { - setSelectedCluster(null); - } + clearSelectedCluster(); setEnrollmentState({ status: 'notStarted' }); } @@ -279,19 +298,46 @@ export function EnrollEksCluster(props: AgentStepProps) { } as EksMeta); } + function showManualHelmDialog(validator: Validator) { + if (!validator.validate()) { + return; + } + + setIsManualHelmDialogShown(true); + } + + async function enrollWithValidation(validator: Validator) { + if (!validator.validate()) { + return; + } + return enroll(); + } + async function enroll() { const integrationName = (agentMeta as EksMeta).awsIntegration.name; setEnrollmentState({ status: 'enrolling' }); try { - const response = await integrationService.enrollEksClusters( - integrationName, - { - region: selectedRegion, - enableAppDiscovery: isAppDiscoveryEnabled, - clusterNames: [selectedCluster.name], - } - ); + const req = { + region: selectedRegion, + enableAppDiscovery: isAppDiscoveryEnabled, + clusterNames: [selectedCluster.name], + extraLabels: customLabels, + }; + let response: EnrollEksClustersResponse; + try { + response = await integrationService.enrollEksClustersV2( + integrationName, + req + ); + } catch (err) { + response = await tryV1Fallback({ + kind: 'enroll-eks', + err, + integrationName, + req, + }); + } const result = response.results?.find( c => c.clusterName === selectedCluster.name @@ -380,7 +426,14 @@ export function EnrollEksCluster(props: AgentStepProps) { isCloud: ctx.isCloud, automaticUpgradesEnabled: ctx.automaticUpgradesEnabled, automaticUpgradesTargetVersion: ctx.automaticUpgradesTargetVersion, - joinLabels: [...selectedCluster.labels, ...selectedCluster.joinLabels], + // The labels from the `selectedCluster` are AWS tags which + // will be imported as is. `joinLabels` are internal Teleport labels + // added to each cluster when listing clusters. + joinLabels: [ + ...selectedCluster.labels, + ...selectedCluster.joinLabels, + ...customLabels, + ], disableAppDiscovery: !isAppDiscoveryEnabled, }); }, @@ -392,6 +445,7 @@ export function EnrollEksCluster(props: AgentStepProps) { ctx.storeUser.state.cluster, isAppDiscoveryEnabled, selectedCluster, + customLabels, ] ); @@ -457,7 +511,7 @@ export function EnrollEksCluster(props: AgentStepProps) { autoDiscovery={isAutoDiscoveryEnabled} fetchStatus={tableData.fetchStatus} selectedCluster={selectedCluster} - onSelectCluster={setSelectedCluster} + onSelectCluster={onSelectCluster} fetchNextPage={fetchNextPage} /> )} @@ -469,32 +523,60 @@ export function EnrollEksCluster(props: AgentStepProps) { /> )} {!isAutoDiscoveryEnabled && ( - - Automatically enroll selected EKS cluster - - - Enroll EKS Cluster - - - { - setIsManualHelmDialogShown(b => !b); - }} - > - Or enroll manually - - - - + + {({ validator }) => ( + <> + {selectedCluster && ( + <> + + Optionally Add More Labels + + + + + )} + + + Automatically enroll selected EKS cluster + + + enrollWithValidation(validator)} + disabled={enrollmentNotAllowed} + mt={2} + mb={2} + > + Enroll EKS Cluster + + + showManualHelmDialog(validator)} + > + Or enroll manually + + + + + + )} + )} {isAutoDiscoveryEnabled && ( { if (joinToken && !command) { setCommand(setJoinTokenAndGetCommand(joinToken)); } + + return () => clearCachedJoinTokenResult(resourceKinds); }, [joinToken, command, setJoinTokenAndGetCommand]); return ( diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.story.tsx similarity index 100% rename from web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx rename to web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.story.tsx diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.test.tsx b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.test.tsx similarity index 100% rename from web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.test.tsx rename to web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.test.tsx diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.tsx similarity index 80% rename from web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx rename to web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.tsx index ef91a9ff0160d..454e7bd8f57a4 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/HelmChart.tsx @@ -16,10 +16,19 @@ * along with this program. If not, see . */ -import { Suspense, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import styled from 'styled-components'; -import { Box, ButtonSecondary, H3, Link, Mark, Subtitle3, Text } from 'design'; +import { + Box, + ButtonSecondary, + Flex, + H3, + Link, + Mark, + Subtitle3, + Text, +} from 'design'; import * as Icons from 'design/Icon'; import { P } from 'design/Text/Text'; import FieldInput from 'shared/components/FieldInput'; @@ -35,6 +44,7 @@ import { WaitingInfo, } from 'teleport/Discover/Shared/HintBox'; import { usePingTeleport } from 'teleport/Discover/Shared/PingTeleportContext'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip'; import { clearCachedJoinTokenResult, useJoinTokenSuspender, @@ -49,16 +59,18 @@ import { ActionButtons, Header, HeaderSubtitle, + LabelsCreater, ResourceKind, TextIcon, useShowHint, -} from '../../Shared'; -import type { AgentStepProps } from '../../types'; +} from '../../../Shared'; +import type { AgentStepProps } from '../../../types'; export default function Container(props: AgentStepProps) { const [namespace, setNamespace] = useState(''); const [clusterName, setClusterName] = useState(''); const [showHelmChart, setShowHelmChart] = useState(false); + const [labels, setLabels] = useState([]); return ( // This outer CatchError and Suspense handles @@ -82,6 +94,9 @@ export default function Container(props: AgentStepProps) { setNamespace={setNamespace} clusterName={clusterName} setClusterName={setClusterName} + labels={labels} + onChangeLabels={setLabels} + generateScript={fallbackProps.retry} /> null} @@ -102,6 +117,9 @@ export default function Container(props: AgentStepProps) { setNamespace={setNamespace} clusterName={clusterName} setClusterName={setClusterName} + labels={labels} + onChangeLabels={setLabels} + processing={true} /> null} @@ -122,6 +140,8 @@ export default function Container(props: AgentStepProps) { setNamespace={setNamespace} clusterName={clusterName} setClusterName={setClusterName} + labels={labels} + onChangeLabels={setLabels} /> null} @@ -138,6 +158,8 @@ export default function Container(props: AgentStepProps) { setNamespace={setNamespace} clusterName={clusterName} setClusterName={setClusterName} + labels={labels} + onChangeLabels={setLabels} /> )} @@ -145,6 +167,12 @@ export default function Container(props: AgentStepProps) { ); } +const resourceKinds = [ + ResourceKind.Kubernetes, + ResourceKind.Application, + ResourceKind.Discovery, +]; + export function HelmChart( props: AgentStepProps & { onEdit: () => void; @@ -152,26 +180,33 @@ export function HelmChart( setNamespace(n: string): void; clusterName: string; setClusterName(c: string): void; + labels: ResourceLabel[]; + onChangeLabels(l: ResourceLabel[]): void; } ) { - const { joinToken, reloadJoinToken } = useJoinTokenSuspender([ - ResourceKind.Kubernetes, - ResourceKind.Application, - ResourceKind.Discovery, - ]); + const { joinToken, reloadJoinToken } = useJoinTokenSuspender({ + resourceKinds, + suggestedLabels: props.labels, + }); + + useEffect(() => { + return () => clearCachedJoinTokenResult(resourceKinds); + }); return ( props.onEdit()} generateScript={reloadJoinToken} namespace={props.namespace} setNamespace={props.setNamespace} clusterName={props.clusterName} setClusterName={props.setClusterName} + labels={props.labels} + onChangeLabels={props.onChangeLabels} /> ); @@ -233,8 +269,11 @@ const StepTwo = ({ setClusterName, error, generateScript, - disabled, + showHelmChart, onEdit, + labels, + onChangeLabels, + processing, }: { error?: Error; generateScript?(): void; @@ -242,11 +281,19 @@ const StepTwo = ({ setNamespace(n: string): void; clusterName: string; setClusterName(c: string): void; - disabled?: boolean; + showHelmChart?: boolean; + processing?: boolean; onEdit: () => void; + labels: ResourceLabel[]; + onChangeLabels(l: ResourceLabel[]): void; }) => { - function handleSubmit(validator: Validator) { - if (!validator.validate()) { + const disabled = showHelmChart || processing; + + function handleSubmit( + inputFieldValidator: Validator, + labelsValidator: Validator + ) { + if (!inputFieldValidator.validate() || !labelsValidator.validate()) { return; } generateScript(); @@ -262,7 +309,7 @@ const StepTwo = ({ - {({ validator }) => ( + {({ validator: inputFieldValidator }) => ( <> setClusterName(e.target.value)} /> - {disabled ? ( - onEdit()} - > - Edit - - ) : ( - handleSubmit(validator)} - > - Next - - )} + + Add Labels (Optional) + + + + {({ validator: labelsValidator }) => ( + <> + + + + {showHelmChart ? ( + onEdit()} + > + Edit + + ) : ( + + handleSubmit(inputFieldValidator, labelsValidator) + } + disabled={processing} + > + Generate Command + + )} + + )} + )} @@ -391,6 +460,7 @@ const InstallHelmChart = ({ nextStep, prevStep, updateAgentMeta, + labels, }: { namespace: string; clusterName: string; @@ -398,6 +468,7 @@ const InstallHelmChart = ({ nextStep(): void; prevStep(): void; updateAgentMeta(a: AgentMeta): void; + labels: ResourceLabel[]; }) => { const ctx = useTeleport(); @@ -477,6 +548,7 @@ const InstallHelmChart = ({ isCloud: ctx.isCloud, automaticUpgradesEnabled: ctx.automaticUpgradesEnabled, automaticUpgradesTargetVersion: ctx.automaticUpgradesTargetVersion, + joinLabels: labels, }); return ( diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/index.ts similarity index 89% rename from web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts rename to web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/index.ts index 0239113fe6f88..b995808c6f4e1 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts +++ b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/HelmChart/index.ts @@ -16,6 +16,6 @@ * along with this program. If not, see . */ -import HelmChart from './HelmChart'; +import HelmChart, { generateCmd } from './HelmChart'; -export { HelmChart }; +export { HelmChart, generateCmd }; diff --git a/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/index.ts b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/index.ts new file mode 100644 index 0000000000000..6c6ef4aff54b0 --- /dev/null +++ b/web/packages/teleport/src/Discover/Kubernetes/SelfHosted/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export * from './HelmChart'; diff --git a/web/packages/teleport/src/Discover/Kubernetes/index.tsx b/web/packages/teleport/src/Discover/Kubernetes/index.tsx index f5b668cab05b4..81595fd4d2282 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/index.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/index.tsx @@ -24,8 +24,8 @@ import { KubeLocation, ResourceSpec } from 'teleport/Discover/SelectResource'; import { AwsAccount, Finished, ResourceKind } from 'teleport/Discover/Shared'; import { DiscoverEvent } from 'teleport/services/userEvent'; -import { HelmChart } from './HelmChart'; import { KubeWrapper } from './KubeWrapper'; +import { HelmChart } from './SelfHosted'; import { SetupAccess } from './SetupAccess'; import { TestConnection } from './TestConnection'; diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx index 7dad3e0ec67de..277c05492f7ac 100644 --- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx +++ b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx @@ -72,7 +72,7 @@ export const Polling: StoryObj = { render() { return ( - + null} /> ); }, @@ -95,7 +95,7 @@ export const PollingSuccess: StoryObj = { render() { return ( - + null} /> ); }, @@ -120,7 +120,7 @@ export const PollingError: StoryObj = { render() { return ( - + null} /> ); }, @@ -139,7 +139,7 @@ export const Processing: StoryObj = { render() { return ( - + null} /> ); }, @@ -163,7 +163,7 @@ export const Failed: StoryObj = { render() { return ( - + null} /> ); }, diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx index 9ce6b53edcb55..7e8de453f7f73 100644 --- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx +++ b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx @@ -16,34 +16,38 @@ * along with this program. If not, see . */ -import React, { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; -import { Box, Indicator, Mark, Text } from 'design'; +import { Box, ButtonSecondary, Flex, Mark, Text } from 'design'; import * as Icons from 'design/Icon'; -import { P } from 'design/Text/Text'; +import { H3, Subtitle3 } from 'design/Text/Text'; +import Validation, { Validator } from 'shared/components/Validation'; import { CatchError } from 'teleport/components/CatchError'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; import cfg from 'teleport/config'; -import { CommandBox } from 'teleport/Discover/Shared/CommandBox'; import { HintBox, SuccessBox, WaitingInfo, } from 'teleport/Discover/Shared/HintBox'; import { usePingTeleport } from 'teleport/Discover/Shared/PingTeleportContext'; +import { ResourceLabelTooltip } from 'teleport/Discover/Shared/ResourceLabelTooltip/ResourceLabelTooltip'; import { clearCachedJoinTokenResult, useJoinTokenSuspender, } from 'teleport/Discover/Shared/useJoinTokenSuspender'; +import { ResourceLabel } from 'teleport/services/agents'; import { JoinToken } from 'teleport/services/joinToken'; -import type { Node } from 'teleport/services/nodes'; +import { Node } from 'teleport/services/nodes'; import { ActionButtons, Header, HeaderSubtitle, + LabelsCreater, ResourceKind, + StyledBox, TextIcon, } from '../../Shared'; import { AgentStepProps } from '../../types'; @@ -51,38 +55,148 @@ import { AgentStepProps } from '../../types'; const SHOW_HINT_TIMEOUT = 1000 * 60 * 5; // 5 minutes export default function Container(props: AgentStepProps) { + const [labels, setLabels] = useState([]); + const [showScript, setShowScript] = useState(false); + + function toggleShowScript(validator: Validator) { + if (!validator.validate()) { + return; + } + setShowScript(!showScript); + } + + const commonProps = { + labels, + onChangeLabels: setLabels, + showScript, + onShowScript: toggleShowScript, + onPrev: props.prevStep, + }; + return ( clearCachedJoinTokenResult([ResourceKind.Server])} fallbackFn={fbProps => ( - + <> + + + )} > - -
+ <> + + + } > - + + + {showScript && } ); } -export function DownloadScript(props: AgentStepProps) { +const Heading = () => ( + <> +
Configure Resource
+ + Install and configure the Teleport SSH Service + + +); + +export function StepOne({ + labels, + onChangeLabels, + showScript, + onShowScript, + error, + processing = false, + onPrev, +}: { + labels: ResourceLabel[]; + onChangeLabels(l: ResourceLabel[]): void; + showScript: boolean; + onShowScript(validator: Validator): void; + error?: Error; + processing?: boolean; + onPrev(): void; +}) { + const nextLabelTxt = labels.length + ? 'Finish Adding Labels' + : 'Skip Adding Labels'; + return ( + <> + +
+

Step 1 (Optional)

+ + Add Labels + + +
+ + {({ validator }) => ( + <> + + {error && ( + + + Encountered Error: {error.message} + + )} + + onShowScript(validator)} + disabled={processing} + > + {showScript && !error ? 'Edit Labels' : nextLabelTxt} + + + + )} + +
+ {(!showScript || processing || error) && ( + null} + disableProceed={true} + onPrev={onPrev} + /> + )} + + ); +} + +export function StepTwoWithActionBtns( + props: AgentStepProps & { labels: ResourceLabel[] } +) { // Fetches join token. - const { joinToken } = useJoinTokenSuspender([ResourceKind.Server]); + const { joinToken } = useJoinTokenSuspender({ + resourceKinds: [ResourceKind.Server], + suggestedLabels: props.labels, + }); // Starts resource querying interval. const { result, active } = usePingTeleport(joinToken); @@ -92,7 +206,10 @@ export function DownloadScript(props: AgentStepProps) { if (active) { const id = window.setTimeout(() => setShowHint(true), SHOW_HINT_TIMEOUT); - return () => window.clearTimeout(id); + return () => { + window.clearTimeout(id); + clearCachedJoinTokenResult([ResourceKind.Server]); + }; } }, [active]); @@ -153,17 +270,22 @@ export function DownloadScript(props: AgentStepProps) { return ( <> -
Configure Resource
- - Install and configure the Teleport Service - -

Run the following command on the server you want to add.

- - - - {hint} + {joinToken && ( + <> + +
+

Step 2

+ + Run the following command on the server you want to add + +
+ +
+ {hint} + + )} { - return ( - <> -
Configure Resource
- - Install and configure the Teleport Service. -
- Run the following command on the server you want to add. -
- {children} - - - ); -}; - function createBashCommand(tokenId: string) { return `sudo bash -c "$(curl -fsSL ${cfg.getNodeScriptUrl(tokenId)})"`; } diff --git a/web/packages/teleport/src/Discover/Shared/LabelsCreater/LabelsCreater.tsx b/web/packages/teleport/src/Discover/Shared/LabelsCreater/LabelsCreater.tsx index dbf829e911fdd..058f5381badee 100644 --- a/web/packages/teleport/src/Discover/Shared/LabelsCreater/LabelsCreater.tsx +++ b/web/packages/teleport/src/Discover/Shared/LabelsCreater/LabelsCreater.tsx @@ -189,10 +189,9 @@ export function LabelsCreater({ })} ); diff --git a/web/packages/teleport/src/Discover/Shared/PingTeleportContext.tsx b/web/packages/teleport/src/Discover/Shared/PingTeleportContext.tsx index 167b98efdd89d..9e43d3127d99b 100644 --- a/web/packages/teleport/src/Discover/Shared/PingTeleportContext.tsx +++ b/web/packages/teleport/src/Discover/Shared/PingTeleportContext.tsx @@ -30,6 +30,7 @@ interface PingTeleportContextState { active: boolean; start: (tokenOrTerm: JoinToken | string) => void; result: T | null; + stop: () => void; } const pingTeleportContext = @@ -117,6 +118,7 @@ export function PingTeleportProvider(props: { active, start, result, + stop: () => setActive(false), }} > {props.children} @@ -137,6 +139,8 @@ export function usePingTeleport(tokenOrTerm: JoinToken | string) { if (!ctx.active && !ctx.result) { ctx.start(tokenOrTerm); } + + return () => ctx.stop(); }, []); return ctx; diff --git a/web/packages/teleport/src/Discover/Shared/ResourceLabelTooltip/ResourceLabelTooltip.tsx b/web/packages/teleport/src/Discover/Shared/ResourceLabelTooltip/ResourceLabelTooltip.tsx index 4feb605ae4692..f0d5ddc8abf5e 100644 --- a/web/packages/teleport/src/Discover/Shared/ResourceLabelTooltip/ResourceLabelTooltip.tsx +++ b/web/packages/teleport/src/Discover/Shared/ResourceLabelTooltip/ResourceLabelTooltip.tsx @@ -37,12 +37,36 @@ export function ResourceLabelTooltip({ resourceKind, toolTipPosition, }: { - resourceKind: 'server' | 'eks' | 'rds' | 'kube' | 'db'; + resourceKind: 'server' | 'eks' | 'rds' | 'kube' | 'db' | 'app'; toolTipPosition?: Position; }) { let tip; switch (resourceKind) { + case 'app': { + tip = ( + <> + Labels allow you to do the following: +
    +
  • + Filter applications by labels when using tsh, tctl, or the web UI. +
  • +
  • + Restrict access to this application with{' '} + + Teleport RBAC + + . Only roles with app_labels that match + these labels will be allowed to access this application. +
  • +
+ + ); + break; + } case 'server': { tip = ( <> diff --git a/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.test.tsx b/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.test.tsx new file mode 100644 index 0000000000000..682edf971a2de --- /dev/null +++ b/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.test.tsx @@ -0,0 +1,155 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +import { ContextProvider } from 'teleport/index'; +import { + DiscoverEventResource, + userEventService, +} from 'teleport/services/userEvent'; +import { ProxyRequiresUpgrade } from 'teleport/services/version/unsupported'; +import TeleportContext from 'teleport/teleportContext'; + +import { DiscoverContextState, DiscoverProvider } from '../useDiscover'; +import { ResourceKind } from './ResourceKind'; +import { + clearCachedJoinTokenResult, + useJoinTokenSuspender, +} from './useJoinTokenSuspender'; + +beforeEach(() => { + jest + .spyOn(userEventService, 'captureDiscoverEvent') + .mockResolvedValue(undefined as never); +}); + +afterEach(() => { + jest.resetAllMocks(); + clearCachedJoinTokenResult([ResourceKind.Server]); +}); + +test('create join token without labels', async () => { + const ctx = new TeleportContext(); + + jest + .spyOn(ctx.joinTokenService, 'fetchJoinTokenV2') + .mockResolvedValue(tokenResp); + + jest + .spyOn(ctx.joinTokenService, 'fetchJoinToken') + .mockResolvedValue(tokenResp); + + const wrapper = ({ children }) => ( + + + {children} + + + ); + + let { result } = renderHook( + () => useJoinTokenSuspender({ resourceKinds: [ResourceKind.Server] }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.joinToken).not.toBeNull(); + }); + + expect(ctx.joinTokenService.fetchJoinTokenV2).toHaveBeenCalledTimes(1); + expect(ctx.joinTokenService.fetchJoinToken).not.toHaveBeenCalled(); + expect(result.current.joinToken).toEqual(tokenResp); +}); + +test('create join token without labels with v1 fallback', async () => { + const ctx = new TeleportContext(); + + jest + .spyOn(ctx.joinTokenService, 'fetchJoinTokenV2') + .mockRejectedValueOnce(new Error(ProxyRequiresUpgrade)); + + jest + .spyOn(ctx.joinTokenService, 'fetchJoinToken') + .mockResolvedValue(tokenResp); + + const wrapper = ({ children }) => ( + + + {children} + + + ); + + let { result } = renderHook( + () => + useJoinTokenSuspender({ + resourceKinds: [ResourceKind.Server], + suggestedLabels: [], + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.joinToken).not.toBeNull(); + }); + + expect(ctx.joinTokenService.fetchJoinTokenV2).toHaveBeenCalledTimes(1); + expect(ctx.joinTokenService.fetchJoinToken).toHaveBeenCalledTimes(1); + expect(result.current.joinToken).toEqual(tokenResp); +}); + +const discoverCtx: DiscoverContextState = { + agentMeta: {}, + currentStep: 0, + nextStep: () => null, + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: { + name: 'Eks', + kind: ResourceKind.Kubernetes, + icon: 'eks', + keywords: [], + event: DiscoverEventResource.KubernetesEks, + }, + exitFlow: () => null, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: () => null, + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, +}; + +const tokenResp = { + allow: undefined, + bot_name: undefined, + content: undefined, + expiry: null, + expiryText: '', + gcp: undefined, + id: undefined, + isStatic: undefined, + method: undefined, + internalResourceId: 'abc', + roles: ['Application'], + safeName: undefined, + suggestedLabels: [], +}; diff --git a/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.ts b/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.ts index 18e7f9e3f007c..22b1f718974d5 100644 --- a/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.ts +++ b/web/packages/teleport/src/Discover/Shared/useJoinTokenSuspender.ts @@ -1,6 +1,6 @@ /** * Teleport - * Copyright (C) 2023 Gravitational, Inc. + * Copyright (C) 2025 Gravitational, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -25,6 +25,7 @@ import { } from 'teleport/Discover/Shared/ResourceKind'; import type { ResourceLabel } from 'teleport/services/agents'; import type { JoinMethod, JoinToken } from 'teleport/services/joinToken'; +import { useV1Fallback } from 'teleport/services/version/unsupported'; import { useDiscover } from '../useDiscover'; @@ -41,11 +42,25 @@ export function clearCachedJoinTokenResult(resourceKinds: ResourceKind[]) { joinTokenCache.delete(resourceKinds.sort().join()); } -export function useJoinTokenSuspender( - resourceKinds: ResourceKind[], - suggestedAgentMatcherLabels: ResourceLabel[] = [], - joinMethod: JoinMethod = 'token' -): { +export function useJoinTokenSuspender({ + resourceKinds, + suggestedAgentMatcherLabels = [], + joinMethod = 'token', + suggestedLabels = [], +}: { + resourceKinds: ResourceKind[]; + /** + * labels used for the agent that will be created + * using a join token (eg: db agent) + */ + suggestedAgentMatcherLabels?: ResourceLabel[]; + joinMethod?: JoinMethod; + /** + * labels for a non-agent resource that will be created + * using a join token (currently only can be applied to server resource kind). + */ + suggestedLabels?: ResourceLabel[]; +}): { joinToken: JoinToken; reloadJoinToken: () => void; } { @@ -54,23 +69,44 @@ export function useJoinTokenSuspender( const [, rerender] = useState(0); + // TODO(kimlisa): DELETE IN 19.0 + const { tryV1Fallback } = useV1Fallback(); + const kindsKey = resourceKinds.sort().join(); function run() { abortController = new AbortController(); + async function fetchJoinToken() { + const req = { + roles: resourceKinds.map(resourceKindToJoinRole), + method: joinMethod, + suggestedAgentMatcherLabels, + suggestedLabels, + }; + + let resp: JoinToken; + try { + resp = await ctx.joinTokenService.fetchJoinTokenV2( + req, + abortController.signal + ); + } catch (err) { + resp = await tryV1Fallback({ + kind: 'create-join-token', + err, + req, + abortSignal: abortController.signal, + ctx, + }); + } + return resp; + } + const result: SuspendResult = { response: null, error: null, - promise: ctx.joinTokenService - .fetchJoinToken( - { - roles: resourceKinds.map(resourceKindToJoinRole), - method: joinMethod, - suggestedAgentMatcherLabels, - }, - abortController.signal - ) + promise: fetchJoinToken() .then(token => { // Probably will never happen, but just in case, otherwise // querying for the resource can return a false positive. diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index d1dfd1a9cea21..e8de4ef2ccb70 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -362,8 +362,12 @@ const cfg = { awsSubnetListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/subnets', - awsAppAccessPath: - '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/aws-app-access', + awsAppAccess: { + create: + '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/aws-app-access', + createV2: + '/v2/webapi/sites/:clusterId/integrations/aws-oidc/:name/aws-app-access', + }, awsConfigureIamAppAccessPath: '/v1/webapi/scripts/integrations/configure/aws-app-access-iam.sh?role=:iamRoleName&awsAccountID=:accountID', @@ -1060,7 +1064,16 @@ const cfg = { getAwsAppAccessUrl(integrationName: string) { const clusterId = cfg.proxyCluster; - return generatePath(cfg.api.awsAppAccessPath, { + return generatePath(cfg.api.awsAppAccess.create, { + clusterId, + name: integrationName, + }); + }, + + getAwsAppAccessUrlV2(integrationName: string) { + const clusterId = cfg.proxyCluster; + + return generatePath(cfg.api.awsAppAccess.createV2, { clusterId, name: integrationName, }); diff --git a/web/packages/teleport/src/services/integrations/integrations.test.ts b/web/packages/teleport/src/services/integrations/integrations.test.ts index 4c86d1a8d96e8..1398f9640999a 100644 --- a/web/packages/teleport/src/services/integrations/integrations.test.ts +++ b/web/packages/teleport/src/services/integrations/integrations.test.ts @@ -188,50 +188,6 @@ test('fetchAwsDatabases response', async () => { }); }); -test('enrollEksClusters without labels calls v1', async () => { - jest.spyOn(api, 'post').mockResolvedValue({}); - - await integrationService.enrollEksClusters('integration', { - region: 'us-east-1', - enableAppDiscovery: false, - clusterNames: ['cluster'], - }); - - expect(api.post).toHaveBeenCalledWith( - cfg.getEnrollEksClusterUrl('integration'), - { - clusterNames: ['cluster'], - enableAppDiscovery: false, - region: 'us-east-1', - }, - null, - undefined - ); -}); - -test('enrollEksClusters with labels calls v2', async () => { - jest.spyOn(api, 'post').mockResolvedValue({}); - - await integrationService.enrollEksClusters('integration', { - region: 'us-east-1', - enableAppDiscovery: false, - clusterNames: ['cluster'], - extraLabels: [{ name: 'env', value: 'staging' }], - }); - - expect(api.post).toHaveBeenCalledWith( - cfg.getEnrollEksClusterUrlV2('integration'), - { - clusterNames: ['cluster'], - enableAppDiscovery: false, - region: 'us-east-1', - extraLabels: [{ name: 'env', value: 'staging' }], - }, - null, - undefined - ); -}); - describe('fetchAwsDatabases() request body formatting', () => { test.each` protocol | expectedEngines | expectedRdsType diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 77fce709395d5..fe4a5b1361464 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -32,6 +32,7 @@ import { AwsOidcPingRequest, AwsOidcPingResponse, AwsRdsDatabase, + CreateAwsAppAccessRequest, DeployEc2InstanceConnectEndpointRequest, DeployEc2InstanceConnectEndpointResponse, Ec2InstanceConnectEndpoint, @@ -292,6 +293,21 @@ export const integrationService = { .then(resp => resp.serviceDashboardUrl); }, + async createAwsAppAccessV2( + integrationName, + req: CreateAwsAppAccessRequest + ): Promise { + return ( + api + .post(cfg.getAwsAppAccessUrlV2(integrationName), req) + .then(makeApp) + // TODO(kimlisa): DELETE IN 19.0 + .catch(withUnsupportedLabelFeatureErrorConversion) + ); + }, + + // TODO(kimlisa): DELETE IN 19.0 + // replaced by createAwsAppAccessV2 that accepts request body async createAwsAppAccess(integrationName): Promise { return api .post(cfg.getAwsAppAccessUrl(integrationName), null) @@ -314,22 +330,12 @@ export const integrationService = { .then(resp => resp.clusterDashboardUrl); }, - async enrollEksClusters( + async enrollEksClustersV2( integrationName: string, req: EnrollEksClustersRequest ): Promise { const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); - // TODO(kimlisa): DELETE IN 19.0 - replaced by v2 endpoint. - if (!req.extraLabels?.length) { - return api.post( - cfg.getEnrollEksClusterUrl(integrationName), - req, - null, - mfaResponse - ); - } - return ( api .post( @@ -343,6 +349,22 @@ export const integrationService = { ); }, + // TODO(kimlisa): DELETE IN 19.0 - replaced by v2 endpoint. + // replaced by enrollEksClustersV2 that accepts labels. + async enrollEksClusters( + integrationName: string, + req: Omit + ): Promise { + const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); + + return api.post( + cfg.getEnrollEksClusterUrl(integrationName), + req, + null, + mfaResponse + ); + }, + fetchEksClusters( integrationName: string, req: ListEksClustersRequest diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index cf81ea33d2d65..551dc86b77286 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -758,3 +758,16 @@ export type AwsDatabaseVpcsResponse = { vpcs: Vpc[]; nextToken: string; }; + +/** + * Object that contains request fields for + * when requesting to create an AWS console app. + * + * This request object is only supported with v2 endpoint. + */ +export type CreateAwsAppAccessRequest = { + /** + * resource labels that will be set as app_server's labels + */ + labels?: Record; +}; diff --git a/web/packages/teleport/src/services/joinToken/joinToken.test.ts b/web/packages/teleport/src/services/joinToken/joinToken.test.ts index 6a45afe0824f0..5a00fbc191864 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.test.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.test.ts @@ -31,14 +31,15 @@ test('fetchJoinToken with an empty request properly sets defaults', () => { jest.spyOn(api, 'post').mockResolvedValue(null); // Test with all empty fields. - svc.fetchJoinToken({} as any); + svc.fetchJoinTokenV2({} as any); expect(api.post).toHaveBeenCalledWith( - cfg.api.discoveryJoinToken.create, + cfg.api.discoveryJoinToken.createV2, { roles: undefined, join_method: 'token', allow: [], suggested_agent_matcher_labels: {}, + suggested_labels: {}, }, null ); @@ -54,34 +55,15 @@ test('fetchJoinToken request fields are set as requested', () => { method: 'iam', suggestedAgentMatcherLabels: [{ name: 'env', value: 'dev' }], }; - svc.fetchJoinToken(mock); + svc.fetchJoinTokenV2(mock); expect(api.post).toHaveBeenCalledWith( - cfg.api.discoveryJoinToken.create, + cfg.api.discoveryJoinToken.createV2, { roles: ['Node'], join_method: 'iam', allow: [{ aws_account: '1234', aws_arn: 'xxxx' }], suggested_agent_matcher_labels: { env: ['dev'] }, - }, - null - ); -}); - -test('fetchJoinToken with labels calls v2 endpoint', () => { - const svc = new JoinTokenService(); - jest.spyOn(api, 'post').mockResolvedValue(null); - - const mock: JoinTokenRequest = { - suggestedLabels: [{ name: 'env', value: 'testing' }], - }; - svc.fetchJoinToken(mock); - expect(api.post).toHaveBeenCalledWith( - cfg.api.discoveryJoinToken.createV2, - { - suggested_labels: { env: ['testing'] }, - suggested_agent_matcher_labels: {}, - join_method: 'token', - allow: [], + suggested_labels: {}, }, null ); diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index 66d6f0b20894f..5043569ee4b27 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -28,28 +28,10 @@ const TeleportTokenNameHeader = 'X-Teleport-TokenName'; class JoinTokenService { // TODO (avatus) refactor this code to eventually use `createJoinToken` - fetchJoinToken( + fetchJoinTokenV2( req: JoinTokenRequest, signal: AbortSignal = null ): Promise { - // TODO(kimlisa): DELETE IN 19.0 - replaced by v2 endpoint. - if (!req.suggestedLabels?.length) { - return api - .post( - cfg.api.discoveryJoinToken.create, - { - roles: req.roles, - join_method: req.method || 'token', - allow: makeAllowField(req.rules || []), - suggested_agent_matcher_labels: makeLabelMapOfStrArrs( - req.suggestedAgentMatcherLabels - ), - }, - signal - ) - .then(makeJoinToken); - } - return ( api .post( @@ -71,6 +53,28 @@ class JoinTokenService { ); } + // TODO(kimlisa): DELETE IN 19.0 + // replaced by fetchJoinTokenV2 that accepts labels. + fetchJoinToken( + req: Omit, + signal: AbortSignal = null + ): Promise { + return api + .post( + cfg.api.discoveryJoinToken.create, + { + roles: req.roles, + join_method: req.method || 'token', + allow: makeAllowField(req.rules || []), + suggested_agent_matcher_labels: makeLabelMapOfStrArrs( + req.suggestedAgentMatcherLabels + ), + }, + signal + ) + .then(makeJoinToken); + } + upsertJoinTokenYAML( req: JoinTokenRequest, tokenName: string diff --git a/web/packages/teleport/src/services/version/unsupported.test.ts b/web/packages/teleport/src/services/version/unsupported.test.ts new file mode 100644 index 0000000000000..d9e955bd0f151 --- /dev/null +++ b/web/packages/teleport/src/services/version/unsupported.test.ts @@ -0,0 +1,79 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook } from '@testing-library/react'; + +import { app } from 'teleport/Discover/AwsMangementConsole/fixtures'; + +import { integrationService } from '../integrations'; +import { ProxyRequiresUpgrade, useV1Fallback } from './unsupported'; + +afterEach(() => { + jest.resetAllMocks(); +}); + +test('with non upgrade proxy related error, re-throws error', async () => { + jest.spyOn(integrationService, 'createAwsAppAccess'); + + let { result } = renderHook(() => useV1Fallback()); + + const err = new Error('some error'); + await expect( + result.current.tryV1Fallback({ + kind: 'create-app-access', + err, + req: {}, + integrationName: 'foo', + }) + ).rejects.toThrow(err); + expect(integrationService.createAwsAppAccess).not.toHaveBeenCalled(); +}); + +test('with upgrade proxy error, with labels, re-throws error', async () => { + jest.spyOn(integrationService, 'createAwsAppAccess'); + + let { result } = renderHook(() => useV1Fallback()); + + const err = new Error(ProxyRequiresUpgrade); + await expect( + result.current.tryV1Fallback({ + kind: 'create-app-access', + err, + req: { labels: { env: 'dev' } }, + integrationName: 'foo', + }) + ).rejects.toThrow(err); + expect(integrationService.createAwsAppAccess).not.toHaveBeenCalled(); +}); + +test('with upgrade proxy error, without labels, runs fallback', async () => { + jest.spyOn(integrationService, 'createAwsAppAccess').mockResolvedValue(app); + + let { result } = renderHook(() => useV1Fallback()); + + const err = new Error(ProxyRequiresUpgrade); + const resp = await result.current.tryV1Fallback({ + kind: 'create-app-access', + err, + req: { labels: {} }, + integrationName: 'foo', + }); + + expect(resp).toEqual(app); + expect(integrationService.createAwsAppAccess).toHaveBeenCalledTimes(1); +}); diff --git a/web/packages/teleport/src/services/version/unsupported.ts b/web/packages/teleport/src/services/version/unsupported.ts index df21c804c8df4..768da42dee3f3 100644 --- a/web/packages/teleport/src/services/version/unsupported.ts +++ b/web/packages/teleport/src/services/version/unsupported.ts @@ -16,7 +16,19 @@ * along with this program. If not, see . */ +import { App } from 'teleport/services/apps/types'; +import { + CreateAwsAppAccessRequest, + EnrollEksClustersRequest, + EnrollEksClustersResponse, + integrationService, +} from 'teleport/services/integrations'; +import TeleportContext from 'teleport/teleportContext'; + import { ApiError } from '../api/parseError'; +import { JoinToken, JoinTokenRequest } from '../joinToken'; + +export const ProxyRequiresUpgrade = 'Ensure all proxies are upgraded'; export function withUnsupportedLabelFeatureErrorConversion( err: unknown @@ -26,8 +38,102 @@ export function withUnsupportedLabelFeatureErrorConversion( 'We could not complete your request. ' + 'Your proxy may be behind the minimum required version ' + `(v17.2.0) to support adding resource labels. ` + - 'Ensure all proxies are upgraded or remove labels and try again.' + `${ProxyRequiresUpgrade} or remove labels and try again.` ); } throw err; } + +type Base = { + err: Error; +}; + +type CreateJoinToken = Base & { + kind: 'create-join-token'; + req: JoinTokenRequest; + ctx: TeleportContext; + abortSignal?: AbortSignal; +}; + +type EnrollEks = Base & { + kind: 'enroll-eks'; + req: EnrollEksClustersRequest; + integrationName: string; +}; + +type CreateAppAccess = Base & { + kind: 'create-app-access'; + req: CreateAwsAppAccessRequest; + integrationName: string; +}; + +type FallbackProps = CreateJoinToken | EnrollEks | CreateAppAccess; + +/** + * TODO(kimlisa): DELETE IN 19.0 + * + * Used to fetch with v1 endpoints as a fallback, if its v2 equivalent + * endpoint failed. + * + * Only supports v1 endpoints with equivalent v2 endpoints related to + * setting resource labels. Related v1 endpoints does not support labels. + * + * Fetch is only performed if the v2 error (passed in as a retry prop for + * function "tryV1Fallback") is a result of requiring a proxy upgrade: + * - if api request does not contain any labels, + * it will retry with the v1 endpoint without user knowledge + * - if api request includes labels, then it will re-throw the error + * + * Any other errors will get re-thrown. + * + * @returns type FallbackProps + */ +export function useV1Fallback() { + function hasLabels(props: FallbackProps): number { + if (props.kind === 'enroll-eks') { + return props.req.extraLabels.length; + } + if (props.kind === 'create-app-access') { + return props.req.labels && Object.keys(props.req.labels).length; + } + if (props.kind === 'create-join-token') { + return props.req.suggestedLabels.length; + } + } + + async function tryV1Fallback(props: CreateAppAccess): Promise; + + async function tryV1Fallback( + props: EnrollEks + ): Promise; + + async function tryV1Fallback(props: CreateJoinToken): Promise; + + async function tryV1Fallback(props: FallbackProps) { + if (!props.err.message.includes(ProxyRequiresUpgrade) || hasLabels(props)) { + throw props.err; + } + + if (props.kind === 'enroll-eks') { + return integrationService.enrollEksClusters( + props.integrationName, + props.req + ); + } + + if (props.kind === 'create-app-access') { + return integrationService.createAwsAppAccess(props.integrationName); + } + + if (props.kind === 'create-join-token') { + return props.ctx.joinTokenService.fetchJoinToken( + props.req, + props.abortSignal + ); + } + } + + return { + tryV1Fallback, + }; +}