diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx index 56ac40a491e68..4f2dbf16ad2cd 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.test.tsx @@ -24,45 +24,53 @@ import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvi import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; -import { isAgentCompatible, CompatibilityError } from './CompatibilityPromise'; +import { + checkAgentCompatibility, + CompatibilityError, +} from './CompatibilityPromise'; describe('isAgentCompatible', () => { const testCases = [ { agentVersion: '2.0.0', proxyVersion: '2.0.0', - isCompatible: true, + expected: 'compatible', }, { agentVersion: '2.1.0', proxyVersion: '2.0.0', - isCompatible: true, + expected: 'compatible', }, { agentVersion: '3.0.0', proxyVersion: '2.0.0', - isCompatible: false, + expected: 'incompatible', }, { agentVersion: '2.0.0', proxyVersion: '3.0.0', - isCompatible: true, + expected: 'compatible', }, { agentVersion: '2.0.0', proxyVersion: '4.0.0', - isCompatible: false, + expected: 'incompatible', + }, + { + agentVersion: '2.0.0', + proxyVersion: '', + expected: 'unknown', }, ]; test.each(testCases)( - 'should agent $agentVersion and cluster $proxyVersion be compatible? $isCompatible', - ({ agentVersion, proxyVersion, isCompatible }) => { + 'should agent $agentVersion and cluster $proxyVersion be compatible? $expected', + ({ agentVersion, proxyVersion, expected }) => { expect( - isAgentCompatible( + checkAgentCompatibility( proxyVersion, makeRuntimeSettings({ appVersion: agentVersion }) ) - ).toBe(isCompatible); + ).toBe(expected); } ); }); diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx index 1ef3222e03ec7..58f6dc52a3cdb 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/CompatibilityPromise.tsx @@ -26,25 +26,32 @@ import { RuntimeSettings } from 'teleterm/mainProcess/types'; const CONNECT_MY_COMPUTER_RELEASE_VERSION = '14.1.0'; const CONNECT_MY_COMPUTER_RELEASE_MAJOR_VERSION = 14; -export function isAgentCompatible( +export type AgentCompatibility = 'unknown' | 'compatible' | 'incompatible'; + +export function checkAgentCompatibility( proxyVersion: string, runtimeSettings: Pick -): boolean { - if (proxyVersion === '') { - return false; +): AgentCompatibility { + // The proxy version is not immediately available + // (it requires fetching a cluster with details). + // Because of that, we have to return 'unknown' when we do not yet know it. + if (!proxyVersion) { + return 'unknown'; } if (runtimeSettings.isLocalBuild) { - return true; + return 'compatible'; } const majorAppVersion = getMajorVersion(runtimeSettings.appVersion); const majorClusterVersion = getMajorVersion(proxyVersion); - return ( - majorAppVersion === majorClusterVersion || + return majorAppVersion === majorClusterVersion || majorAppVersion === majorClusterVersion - 1 // app one major version behind the cluster - ); + ? 'compatible' + : 'incompatible'; } -export function CompatibilityError(): JSX.Element { +export function CompatibilityError(props: { + hideAlert?: boolean; +}): JSX.Element { const { proxyVersion, appVersion } = useVersions(); const clusterMajorVersion = getMajorVersion(proxyVersion); @@ -86,7 +93,11 @@ export function CompatibilityError(): JSX.Element { return ( <> - Detected an incompatible agent version. + {!props.hideAlert && ( + + The agent version is not compatible with the cluster version + + )} The cluster is on version {proxyVersion} while Teleport Connect is on version {appVersion}. Per our{' '} diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx index 5a17b0eb92436..56cb32cd802c1 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx @@ -66,11 +66,14 @@ export function DocumentConnectMyComputerSetup() { function Information(props: { onSetUpAgentClick(): void }) { const { systemUsername, hostname, roleName, clusterName } = useAgentProperties(); - const { isAgentCompatible } = useConnectMyComputerContext(); + const { agentCompatibility } = useConnectMyComputerContext(); + const isAgentIncompatible = agentCompatibility === 'incompatible'; + const isAgentIncompatibleOrUnknown = + agentCompatibility === 'incompatible' || agentCompatibility === 'unknown'; return ( <> - {!isAgentCompatible && ( + {isAgentIncompatible && ( <> @@ -97,7 +100,7 @@ function Information(props: { onSetUpAgentClick(): void }) { css={` display: block; `} - disabled={!isAgentCompatible} + disabled={isAgentIncompatibleOrUnknown} onClick={props.onSetUpAgentClick} data-testid="start-setup" > diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.story.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.story.tsx index d86d3ffa67f03..f7969ce189351 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.story.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.story.tsx @@ -27,7 +27,10 @@ import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { AgentProcessState } from 'teleterm/mainProcess/types'; import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks'; -import { ConnectMyComputerContextProvider } from '../connectMyComputerContext'; +import { + AgentCompatibilityError, + ConnectMyComputerContextProvider, +} from '../connectMyComputerContext'; import { DocumentConnectMyComputerStatus } from './DocumentConnectMyComputerStatus'; @@ -107,6 +110,24 @@ export function AgentVersionTooNew() { ); } +export function AgentVersionTooNewWithFailedAutoStart() { + const appContext = new MockAppContext({ appVersion: '17.0.0' }); + + appContext.connectMyComputerService.downloadAgent = () => + Promise.reject(new AgentCompatibilityError('incompatible')); + appContext.connectMyComputerService.isAgentConfigFileCreated = () => + Promise.resolve(true); + + return ( + + ); +} + // Shows only cluster upgrade instructions. // Downgrading the app would result in installing a version that doesn't support 'Connect My Computer'. // DELETE IN 17.0.0 (gzdunek): by the time 17.0 releases, 14.x will no longer be @@ -151,6 +172,7 @@ function ShowState(props: { agentProcessState: AgentProcessState; appContext?: AppContext; proxyVersion?: string; + autoStart?: boolean; }) { const cluster = makeRootCluster({ proxyVersion: props.proxyVersion || makeRuntimeSettings().appVersion, @@ -194,11 +216,27 @@ function ShowState(props: { await wait(3_000); throw new Error('TIMEOUT. Cannot find node.'); }; + appContext.configService.set('feature.connectMyComputer', true); appContext.clustersService.state.clusters.set(cluster.uri, cluster); appContext.workspacesService.setState(draftState => { draftState.rootClusterUri = cluster.uri; + draftState.workspaces = { + [cluster.uri]: { + localClusterUri: cluster.uri, + documents: [], + location: '/docs/1234', + accessRequests: undefined, + }, + }; }); + if (props.autoStart) { + appContext.workspacesService.setConnectMyComputerAutoStart( + cluster.uri, + true + ); + } + return ( diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.tsx index 80863d968d228..7a973f22eea8a 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerStatus/DocumentConnectMyComputerStatus.tsx @@ -74,11 +74,14 @@ export function DocumentConnectMyComputerStatus( isAgentConfiguredAttempt, markAgentAsNotConfigured, removeAgent, - isAgentCompatible, + agentCompatibility, } = useConnectMyComputerContext(); const { rootClusterUri } = useWorkspaceContext(); const { roleName, systemUsername, hostname } = useAgentProperties(); const { proxyVersion, appVersion, isLocalBuild } = useVersions(); + const isAgentIncompatible = agentCompatibility === 'incompatible'; + const isAgentIncompatibleOrUnknown = + agentCompatibility === 'incompatible' || agentCompatibility === 'unknown'; const prettyCurrentAction = prettifyCurrentAction(currentAction); @@ -135,7 +138,11 @@ export function DocumentConnectMyComputerStatus( const showConnectAndStopAgentButtons = isRunning || isKilling; const disableConnectAndStopAgentButtons = isKilling; const disableStartAgentButton = - isDownloading || isStarting || isRemoving || isRemoved; + isDownloading || + isStarting || + isRemoving || + isRemoved || + isAgentIncompatibleOrUnknown; return ( @@ -240,8 +247,19 @@ export function DocumentConnectMyComputerStatus( )} {prettyCurrentAction.logs && } - {!isAgentCompatible ? ( - + {isAgentIncompatible ? ( + ) : ( <> diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx index 321d8a9897fbd..1bc5a1a5a631a 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx @@ -39,12 +39,11 @@ export function NavigationMenu() { const iconRef = useRef(); const [isMenuOpened, setIsMenuOpened] = useState(false); const { documentsService, rootClusterUri } = useWorkspaceContext(); - const { isAgentConfiguredAttempt, isAgentCompatible, currentAction, canUse } = + const { isAgentConfiguredAttempt, currentAction, canUse } = useConnectMyComputerContext(); const indicatorStatus = getIndicatorStatus( currentAction, - isAgentConfiguredAttempt, - isAgentCompatible + isAgentConfiguredAttempt ); if (!canUse) { @@ -113,28 +112,15 @@ export function NavigationMenu() { function getIndicatorStatus( currentAction: CurrentAction, - isAgentConfiguredAttempt: Attempt, - isAgentCompatible: boolean + isAgentConfiguredAttempt: Attempt ): IndicatorStatus { if (isAgentConfiguredAttempt.status === 'error') { return 'error'; } - const isAgentConfigured = - isAgentConfiguredAttempt.status === 'success' && - isAgentConfiguredAttempt.data; - - if (!isAgentConfigured) { - return ''; - } - if (currentAction.kind === 'observe-process') { switch (currentAction.agentProcessState.status) { case 'not-started': { - if (!isAgentCompatible) { - return 'error'; - } - return ''; } case 'error': { @@ -179,14 +165,18 @@ export const MenuIcon = forwardRef( title="Open Connect My Computer" > - {indicatorStatusToStyledStatus(props.indicatorStatus)} + {props.indicatorStatus === 'error' ? ( + + ) : ( + indicatorStatusToStyledStatus(props.indicatorStatus) + )} ); } ); const indicatorStatusToStyledStatus = ( - indicatorStatus: IndicatorStatus + indicatorStatus: '' | 'processing' | 'success' ): JSX.Element => { return ( svg { + width: 14px; + height: 14px; + } +`; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx index bb224169f3663..0f1199440bb96 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/UpgradeAgentSuggestion.tsx @@ -22,7 +22,7 @@ import { compareSemVers } from 'shared/utils/semVer'; import { RuntimeSettings } from 'teleterm/mainProcess/types'; -import { isAgentCompatible } from './CompatibilityPromise'; +import { checkAgentCompatibility } from './CompatibilityPromise'; export function shouldShowAgentUpgradeSuggestion( proxyVersion: string, @@ -32,7 +32,7 @@ export function shouldShowAgentUpgradeSuggestion( const isClusterOnOlderVersion = compareSemVers(proxyVersion, appVersion) === 1; return ( - isAgentCompatible(proxyVersion, runtimeSettings) && + checkAgentCompatibility(proxyVersion, runtimeSettings) === 'compatible' && isClusterOnOlderVersion && !isLocalBuild ); diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx index 22915f2f64f50..a8b17e09d22ea 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx @@ -33,6 +33,7 @@ import { import { createMockConfigService } from 'teleterm/services/config/fixtures/mocks'; import { + AgentCompatibilityError, AgentProcessError, ConnectMyComputerContextProvider, useConnectMyComputerContext, @@ -167,7 +168,7 @@ test('starting the agent flips the workspace autoStart flag to true', async () = expect( appContext.workspacesService.getConnectMyComputerAutoStart(rootCluster.uri) - ).toBeTruthy(); + ).toBe(true); }); test('killing the agent flips the workspace autoStart flag to false', async () => { @@ -182,7 +183,44 @@ test('killing the agent flips the workspace autoStart flag to false', async () = expect( appContext.workspacesService.getConnectMyComputerAutoStart(rootCluster.uri) - ).toBeFalsy(); + ).toBe(false); +}); + +test('failed autostart flips the workspace autoStart flag to false', async () => { + const { appContext, rootCluster } = getMocksWithConnectMyComputerEnabled(); + + let currentAgentProcessState: AgentProcessState = { + status: 'not-started', + }; + jest + .spyOn(appContext.mainProcessClient, 'getAgentState') + .mockImplementation(() => currentAgentProcessState); + jest + .spyOn(appContext.connectMyComputerService, 'isAgentConfigFileCreated') + .mockResolvedValue(true); + jest + .spyOn(appContext.connectMyComputerService, 'downloadAgent') + .mockRejectedValue(new AgentCompatibilityError('incompatible')); + + appContext.workspacesService.setConnectMyComputerAutoStart( + rootCluster.uri, + true + ); + + const { result, waitFor } = renderUseConnectMyComputerContextHook( + appContext, + rootCluster + ); + + await waitFor( + () => + result.current.currentAction.kind === 'download' && + result.current.currentAction.attempt.status === 'error' + ); + + expect( + appContext.workspacesService.getConnectMyComputerAutoStart(rootCluster.uri) + ).toBe(false); }); test('starts the agent automatically if the workspace autoStart flag is true', async () => { @@ -215,9 +253,10 @@ test('starts the agent automatically if the workspace autoStart flag is true', a return { cleanup: () => eventEmitter.off('', listener) }; }); - jest - .spyOn(appContext.workspacesService, 'getConnectMyComputerAutoStart') - .mockReturnValue(true); + appContext.workspacesService.setConnectMyComputerAutoStart( + rootCluster.uri, + true + ); const { result, waitFor } = renderUseConnectMyComputerContextHook( appContext, diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx index 716244fb9631d..9cd43d7ac4ab9 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx @@ -43,7 +43,10 @@ import { assertUnreachable, retryWithRelogin } from '../utils'; import { hasConnectMyComputerPermissions } from './permissions'; -import { isAgentCompatible as checkIfAgentIsComptabile } from './CompatibilityPromise'; +import { + checkAgentCompatibility, + AgentCompatibility, +} from './CompatibilityPromise'; import type { AgentProcessState, @@ -88,7 +91,7 @@ export interface ConnectMyComputerContext { isAgentConfiguredAttempt: Attempt; markAgentAsConfigured(): void; markAgentAsNotConfigured(): void; - isAgentCompatible: boolean; + agentCompatibility: AgentCompatibility; } const ConnectMyComputerContext = createContext(null); @@ -135,9 +138,9 @@ export const ConnectMyComputerContextProvider: FC<{ // https://github.com/gravitational/teleport/blob/master/rfd/0133-connect-my-computer.md#access-to-ui-and-autostart return isFeatureFlagEnabled && (hasPermissions || isAgentConfigured); }, [configService, isAgentConfigured, mainProcessClient, rootCluster]); - const isAgentCompatible = useMemo( + const agentCompatibility = useMemo( () => - checkIfAgentIsComptabile( + checkAgentCompatibility( rootCluster.proxyVersion, mainProcessClient.getRuntimeSettings() ), @@ -156,18 +159,27 @@ export const ConnectMyComputerContextProvider: FC<{ } ); + const checkCompatibility = useCallback(() => { + if (agentCompatibility !== 'compatible') { + throw new AgentCompatibilityError(agentCompatibility); + } + }, [agentCompatibility]); + const [downloadAgentAttempt, downloadAgent, setDownloadAgentAttempt] = useAsync( useCallback(async () => { setCurrentActionKind('download'); + checkCompatibility(); await connectMyComputerService.downloadAgent(); - }, [connectMyComputerService]) + }, [connectMyComputerService, checkCompatibility]) ); const [startAgentAttempt, startAgent] = useAsync( useCallback(async () => { setCurrentActionKind('start'); + checkCompatibility(); + await connectMyComputerService.runAgent(rootClusterUri); const abortController = createAbortController(); @@ -204,15 +216,19 @@ export const ConnectMyComputerContextProvider: FC<{ rootClusterUri, usageService, workspacesService, + checkCompatibility, ]) ); const downloadAndStartAgent = useCallback(async () => { - const [, error] = await downloadAgent(); + let [, error] = await downloadAgent(); if (error) { - return; + throw error; + } + [, error] = await startAgent(); + if (error) { + throw error; } - await startAgent(); }, [downloadAgent, startAgent]); const [killAgentAttempt, killAgent] = useAsync( @@ -352,16 +368,32 @@ export const ConnectMyComputerContextProvider: FC<{ const agentIsNotStarted = currentAction.kind === 'observe-process' && currentAction.agentProcessState.status === 'not-started'; + const isAgentCompatibilityKnown = agentCompatibility !== 'unknown'; useEffect(() => { const shouldAutoStartAgent = isAgentConfigured && canUse && - isAgentCompatible && + // Agent compatibility is known only after we fetch full cluster details, so we have to wait + // for that until we attempt to autostart the agent. Otherwise startAgent would return an + // error. + isAgentCompatibilityKnown && workspacesService.getConnectMyComputerAutoStart(rootClusterUri) && agentIsNotStarted; + if (shouldAutoStartAgent) { - downloadAndStartAgent(); + (async () => { + try { + await downloadAndStartAgent(); + } catch (error) { + // Turn off autostart if it fails, otherwise the user wouldn't be able to turn it off by + // themselves. + workspacesService.setConnectMyComputerAutoStart( + rootClusterUri, + false + ); + } + })(); } }, [ canUse, @@ -370,7 +402,7 @@ export const ConnectMyComputerContextProvider: FC<{ isAgentConfigured, rootClusterUri, workspacesService, - isAgentCompatible, + isAgentCompatibilityKnown, ]); return ( @@ -390,7 +422,7 @@ export const ConnectMyComputerContextProvider: FC<{ markAgentAsNotConfigured, isAgentConfiguredAttempt, removeAgent, - isAgentCompatible, + agentCompatibility, }} children={children} /> @@ -464,6 +496,33 @@ export class NodeWaitJoinTimeout extends Error { } } +export class AgentCompatibilityError extends Error { + constructor( + public readonly agentCompatibility: Exclude< + AgentCompatibility, + 'compatible' + > + ) { + let message: string; + switch (agentCompatibility) { + case 'incompatible': { + message = + 'The agent version is not compatible with the cluster version'; + break; + } + case 'unknown': { + message = 'The compatibility of the agent could not be established'; + break; + } + default: { + throw assertUnreachable(agentCompatibility); + } + } + super(message); + this.name = 'AgentCompatibilityError'; + } +} + /** * wait is like wait from the shared package, but it works with TshAbortSignal. * TODO(ravicious): Refactor TshAbortSignal so that its interface is the same as AbortSignal. diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index 8b3d86f1b37f3..1f4b46c670680 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -96,9 +96,10 @@ export function DocumentsRenderer(props: { )} {workspace.rootClusterUri === workspacesService.getRootClusterUri() && + props.topBarContainerRef.current && createPortal( , - props.topBarContainerRef?.current + props.topBarContainerRef.current )}