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.
-
+ 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.
+
+
+ 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 => (
- null}>
-
-
- Encountered Error: {fbProps.error.message}
-
-
+ <>
+
+
+ >
)}
>
- null}>
-
-
-
-
-
+ <>
+
+
+ >
}
>
-
+
+
+ {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 (
+ <>
+
+
+
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,
+ };
+}