diff --git a/jest.config.js b/jest.config.js index ce5e333ef3685..699e0ad5b12ce 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,8 @@ const config = require('@gravitational/build/jest/config'); process.env.TZ = 'UTC'; +const esModules = ['strip-ansi-stream', 'ansi-regex'].join('|'); + /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { ...config, @@ -13,6 +15,7 @@ module.exports = { // '**/packages/design/src/**/*.jsx', '**/packages/shared/components/**/*.jsx', ], + transformIgnorePatterns: [`/node_modules/(?!${esModules})`], coverageReporters: ['text-summary', 'lcov'], setupFilesAfterEnv: ['/web/packages/shared/setupTests.tsx'], }; diff --git a/web/packages/teleterm/package.json b/web/packages/teleterm/package.json index 60968a08b10e0..d4828cdc2835d 100644 --- a/web/packages/teleterm/package.json +++ b/web/packages/teleterm/package.json @@ -31,6 +31,7 @@ "@types/tar-fs": "^2.0.1", "emittery": "^1.0.1", "node-pty": "0.11.0-beta29", + "strip-ansi-stream": "^2.0.1", "tar-fs": "^3.0.3" }, "devDependencies": { diff --git a/web/packages/teleterm/src/mainProcess/agentDownloader/agentDownloader.ts b/web/packages/teleterm/src/mainProcess/agentDownloader/agentDownloader.ts index 412ce300541ca..815572726ab5d 100644 --- a/web/packages/teleterm/src/mainProcess/agentDownloader/agentDownloader.ts +++ b/web/packages/teleterm/src/mainProcess/agentDownloader/agentDownloader.ts @@ -45,10 +45,8 @@ interface AgentBinary { /** * Downloads and unpacks the agent binary, if it has not already been downloaded. * - * The agent version to download is taken from settings.appVersion if it is not a dev version (1.0.0-dev). - * The settings.appVersion is set to a real version only for packaged apps that went through our CI build pipeline. - * In local builds, both for the development version and for packaged apps, settings.appVersion is set to 1.0.0-dev. - * In those cases, we fetch the latest available stable version of the agent. + * The agent version to download is taken from settings.appVersion if settings.isLocalBuild is false. + * If it isn't, we fetch the latest available stable version of the agent. * CONNECT_CMC_AGENT_VERSION is available as an escape hatch for cases where we want to fetch a different version. */ export async function downloadAgent( @@ -56,7 +54,7 @@ export async function downloadAgent( settings: RuntimeSettings, env: Record ): Promise { - const version = await calculateAgentVersion(settings.appVersion, env); + const version = await calculateAgentVersion(settings, env); if ( await isCorrectAgentVersionAlreadyDownloaded( @@ -87,11 +85,11 @@ export async function downloadAgent( } async function calculateAgentVersion( - appVersion: string, + settings: RuntimeSettings, env: Record ): Promise { - if (appVersion !== '1.0.0-dev') { - return appVersion; + if (!settings.isLocalBuild) { + return settings.appVersion; } if (env.CONNECT_CMC_AGENT_VERSION) { return env.CONNECT_CMC_AGENT_VERSION; diff --git a/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.test.ts b/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.test.ts new file mode 100644 index 0000000000000..213dcb99460bd --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.test.ts @@ -0,0 +1,151 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import path from 'node:path'; + +import Logger, { NullService } from 'teleterm/logger'; +import { RootClusterUri } from 'teleterm/ui/uri'; + +import { makeRuntimeSettings } from '../fixtures/mocks'; +import { AgentProcessState } from '../types'; + +import { AgentRunner } from './agentRunner'; + +beforeEach(() => { + Logger.init(new NullService()); +}); + +const userDataDir = '/Users/test/Application Data/Teleport Connect'; +const agentBinaryPath = path.join(__dirname, 'agentTestProcess.mjs'); +const rootClusterUri: RootClusterUri = '/clusters/cluster.local'; + +test('agent process starts with correct arguments', async () => { + const agentRunner = new AgentRunner( + makeRuntimeSettings({ + agentBinaryPath, + userDataDir, + }), + () => {} + ); + + try { + const agentProcess = await agentRunner.start(rootClusterUri); + + expect(agentProcess.spawnargs).toEqual([ + agentBinaryPath, + 'start', + `--config=${userDataDir}/agents/cluster.local/config.yaml`, + ]); + } finally { + await agentRunner.killAll(); + } +}); + +test('previous agent process is killed when a new one is started', async () => { + const agentRunner = new AgentRunner( + makeRuntimeSettings({ + agentBinaryPath, + userDataDir, + }), + () => {} + ); + + try { + const firstProcess = await agentRunner.start(rootClusterUri); + await agentRunner.start(rootClusterUri); + + expect(firstProcess.killed).toBeTruthy(); + } finally { + await agentRunner.killAll(); + } +}); + +test('status updates are sent on a successful start', async () => { + const updateSender = jest.fn(); + const agentRunner = new AgentRunner( + makeRuntimeSettings({ + agentBinaryPath, + userDataDir, + }), + updateSender + ); + + try { + expect(agentRunner.getState(rootClusterUri)).toBeUndefined(); + const agentProcess = await agentRunner.start(rootClusterUri); + expect(agentRunner.getState(rootClusterUri)).toStrictEqual({ + status: 'not-started', + } as AgentProcessState); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject('Process start timed out.'), + 4_000 + ); + agentProcess.once('spawn', () => { + resolve(undefined); + clearTimeout(timeout); + }); + }); + const runningState: AgentProcessState = { status: 'running' }; + expect(agentRunner.getState(rootClusterUri)).toStrictEqual(runningState); + expect(updateSender).toHaveBeenCalledWith(rootClusterUri, runningState); + + await agentRunner.kill(rootClusterUri); + const exitedState: AgentProcessState = { + status: 'exited', + code: null, + stackTrace: undefined, + exitedSuccessfully: true, + signal: 'SIGTERM', + }; + expect(agentRunner.getState(rootClusterUri)).toStrictEqual(exitedState); + expect(updateSender).toHaveBeenCalledWith(rootClusterUri, exitedState); + + expect(updateSender).toHaveBeenCalledTimes(2); + } finally { + await agentRunner.killAll(); + } +}); + +test('status updates are sent on a failed start', async () => { + const updateSender = jest.fn(); + const nonExisingPath = path.join( + __dirname, + 'agentTestProcess-nonExisting.mjs' + ); + const agentRunner = new AgentRunner( + makeRuntimeSettings({ + agentBinaryPath: nonExisingPath, + userDataDir, + }), + updateSender + ); + + try { + const agentProcess = await agentRunner.start(rootClusterUri); + await new Promise(resolve => agentProcess.on('error', resolve)); + + expect(updateSender).toHaveBeenCalledTimes(1); + const errorState: AgentProcessState = { + status: 'error', + message: expect.stringContaining('ENOENT'), + }; + expect(agentRunner.getState(rootClusterUri)).toStrictEqual(errorState); + expect(updateSender).toHaveBeenCalledWith(rootClusterUri, errorState); + } finally { + await agentRunner.killAll(); + } +}); diff --git a/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.ts b/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.ts new file mode 100644 index 0000000000000..ba0e766efafd0 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.ts @@ -0,0 +1,179 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawn, ChildProcess } from 'node:child_process'; +import os from 'node:os'; + +import stripAnsiStream from 'strip-ansi-stream'; + +import Logger from 'teleterm/logger'; +import { RootClusterUri } from 'teleterm/ui/uri'; + +import { generateAgentConfigPaths } from '../createAgentConfigFile'; +import { AgentProcessState, RuntimeSettings } from '../types'; +import { terminateWithTimeout } from '../terminateWithTimeout'; + +const MAX_STDERR_LINES = 10; + +export class AgentRunner { + private logger = new Logger('AgentRunner'); + private agentProcesses = new Map< + RootClusterUri, + { + process: ChildProcess; + state: AgentProcessState; + } + >(); + + constructor( + private settings: RuntimeSettings, + private sendProcessState: ( + rootClusterUri: RootClusterUri, + state: AgentProcessState + ) => void + ) {} + + /** + * Starts a new agent process. + * If an existing process exists for the given root cluster, the old one will be killed. + */ + async start(rootClusterUri: RootClusterUri): Promise { + if (this.agentProcesses.has(rootClusterUri)) { + await this.kill(rootClusterUri); + } + + const { agentBinaryPath } = this.settings; + const { configFile } = generateAgentConfigPaths( + this.settings, + rootClusterUri + ); + + const args = [ + 'start', + `--config=${configFile}`, + this.settings.isLocalBuild && '--skip-version-check', + ].filter(Boolean); + + this.logger.info( + `Starting agent for ${rootClusterUri} from ${agentBinaryPath} with arguments ${args.join( + ' ' + )}` + ); + + const agentProcess = spawn(agentBinaryPath, args, { + windowsHide: true, + }); + + this.agentProcesses.set(rootClusterUri, { + process: agentProcess, + state: { status: 'not-started' }, + }); + this.addListeners(rootClusterUri, agentProcess); + + return agentProcess; + } + + getState(rootClusterUri: RootClusterUri): AgentProcessState | undefined { + return this.agentProcesses.get(rootClusterUri)?.state; + } + + async kill(rootClusterUri: RootClusterUri): Promise { + const agent = this.agentProcesses.get(rootClusterUri); + if (!agent) { + this.logger.warn(`Cannot get an agent to kill for ${rootClusterUri}`); + return; + } + await terminateWithTimeout(agent.process); + this.logger.info(`Killed agent for ${rootClusterUri}`); + } + + async killAll(): Promise { + const processes = Array.from(this.agentProcesses.values()); + await Promise.all( + processes.map(async agent => { + await terminateWithTimeout(agent.process); + }) + ); + } + + private addListeners( + rootClusterUri: RootClusterUri, + process: ChildProcess + ): void { + // Teleport logs output to stderr. + let stderrOutput = ''; + process.stderr.setEncoding('utf-8'); + process.stderr.pipe(stripAnsiStream()).on('data', (error: string) => { + stderrOutput += error; + stderrOutput = limitProcessOutputLines(stderrOutput); + }); + + const spawnHandler = () => { + this.updateProcessState(rootClusterUri, { + status: 'running', + }); + }; + + const errorHandler = (error: Error) => { + process.off('spawn', spawnHandler); + + this.updateProcessState(rootClusterUri, { + status: 'error', + message: `${error}`, + }); + }; + + const exitHandler = ( + code: number | null, + signal: NodeJS.Signals | null + ) => { + // Remove handlers when the process exits. + process.off('error', errorHandler); + process.off('spawn', spawnHandler); + + const exitedSuccessfully = code === 0 || signal === 'SIGTERM'; + + this.updateProcessState(rootClusterUri, { + status: 'exited', + code, + signal, + exitedSuccessfully, + stackTrace: exitedSuccessfully ? undefined : stderrOutput, + }); + }; + + process.once('spawn', spawnHandler); + process.once('error', errorHandler); + process.once('exit', exitHandler); + } + + private updateProcessState( + rootClusterUri: RootClusterUri, + state: AgentProcessState + ): void { + this.logger.info( + `Updating agent state ${rootClusterUri}: ${JSON.stringify(state)}` + ); + + const agent = this.agentProcesses.get(rootClusterUri); + agent.state = state; + this.sendProcessState(rootClusterUri, state); + } +} + +function limitProcessOutputLines(output: string): string { + return output.split(os.EOL).slice(-MAX_STDERR_LINES).join(os.EOL); +} diff --git a/web/packages/teleterm/src/mainProcess/agentRunner/agentTestProcess.mjs b/web/packages/teleterm/src/mainProcess/agentRunner/agentTestProcess.mjs new file mode 100755 index 0000000000000..bdf461abea9f1 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/agentRunner/agentTestProcess.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import { setTimeout } from 'node:timers/promises'; + +await setTimeout(10_000); \ No newline at end of file diff --git a/web/packages/teleterm/src/mainProcess/agentRunner/index.ts b/web/packages/teleterm/src/mainProcess/agentRunner/index.ts new file mode 100644 index 0000000000000..a6e9f12447ef9 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/agentRunner/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './agentRunner'; diff --git a/web/packages/teleterm/src/mainProcess/createAgentConfigFile.test.ts b/web/packages/teleterm/src/mainProcess/createAgentConfigFile.test.ts index 19d5f2160ad22..8c35705cd5236 100644 --- a/web/packages/teleterm/src/mainProcess/createAgentConfigFile.test.ts +++ b/web/packages/teleterm/src/mainProcess/createAgentConfigFile.test.ts @@ -17,9 +17,13 @@ import childProcess from 'node:child_process'; import fs from 'node:fs/promises'; +import { RootClusterUri } from 'teleterm/ui/uri'; import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks'; -import { createAgentConfigFile } from './createAgentConfigFile'; +import { + createAgentConfigFile, + generateAgentConfigPaths, +} from './createAgentConfigFile'; jest.mock('node:child_process'); jest.mock('node:fs'); @@ -40,7 +44,7 @@ test('teleport configure is called with proper arguments', async () => { '/Users/test/Caches/Teleport Connect/teleport/teleport'; const token = '8f50fd5d-38e8-4e96-baea-e9b882bb433b'; const proxy = 'cluster.local:3080'; - const profileName = 'cluster.local'; + const rootClusterUri: RootClusterUri = '/clusters/cluster.local'; const labels = [ { name: 'teleport.dev/connect-my-computer/owner', @@ -61,7 +65,7 @@ test('teleport configure is called with proper arguments', async () => { { token, proxy, - profileName, + rootClusterUri, labels, } ) @@ -72,8 +76,8 @@ test('teleport configure is called with proper arguments', async () => { [ 'node', 'configure', - `--output=${userDataDir}/agents/${profileName}/config.yaml`, - `--data-dir=${userDataDir}/agents/${profileName}/data`, + `--output=${userDataDir}/agents/cluster.local/config.yaml`, + `--data-dir=${userDataDir}/agents/cluster.local/data`, `--proxy=${proxy}`, `--token=${token}`, `--labels=${labels[0].name}=${labels[0].value},${labels[1].name}=${labels[1].value}`, @@ -87,7 +91,7 @@ test('teleport configure is called with proper arguments', async () => { test('previous config file is removed before calling teleport configure', async () => { const userDataDir = '/Users/test/Application Data/Teleport Connect'; - const profileName = 'cluster.local'; + const rootClusterUri: RootClusterUri = '/clusters/cluster.local'; await expect( createAgentConfigFile( @@ -97,45 +101,35 @@ test('previous config file is removed before calling teleport configure', async { token: '', proxy: '', - profileName, + rootClusterUri, labels: [], } ) ).resolves.toBeUndefined(); expect(fs.rm).toHaveBeenCalledWith( - `${userDataDir}/agents/${profileName}/config.yaml` + `${userDataDir}/agents/cluster.local/config.yaml` ); }); -test('throws when profileName is not a valid path segment', async () => { - await expect( - createAgentConfigFile( +test('throws when rootClusterUri does not contain a valid path segment', () => { + expect(() => + generateAgentConfigPaths( makeRuntimeSettings({ userDataDir: '/Users/test/Application Data/Teleport Connect', }), - { - token: '', - proxy: '', - profileName: '/cluster', - labels: [], - } + '/clusters/../not_valid' ) - ).rejects.toThrow('The agent config file path is incorrect'); + ).toThrow('The agent config path is incorrect'); }); -test('throws when profileName is undefined', async () => { - await expect( - createAgentConfigFile( +test('throws when rootClusterUri is undefined', () => { + expect(() => + generateAgentConfigPaths( makeRuntimeSettings({ userDataDir: '/Users/test/Application Data/Teleport Connect', }), - { - token: '', - proxy: '', - profileName: undefined, - labels: [], - } + '/clusters/' ) - ).rejects.toThrow('The "path" argument must be of type string'); + ).toThrow('Incorrect root cluster URI'); }); diff --git a/web/packages/teleterm/src/mainProcess/createAgentConfigFile.ts b/web/packages/teleterm/src/mainProcess/createAgentConfigFile.ts index cc397c5c39f0c..4996fccac079e 100644 --- a/web/packages/teleterm/src/mainProcess/createAgentConfigFile.ts +++ b/web/packages/teleterm/src/mainProcess/createAgentConfigFile.ts @@ -19,12 +19,13 @@ import { execFile } from 'node:child_process'; import { rm } from 'node:fs/promises'; import path from 'node:path'; +import { RootClusterUri, routing } from 'teleterm/ui/uri'; import { RuntimeSettings } from 'teleterm/mainProcess/types'; import type * as tsh from 'teleterm/services/tshd/types'; export interface AgentConfigFileClusterProperties { - profileName: string; + rootClusterUri: RootClusterUri; proxy: string; token: string; labels: tsh.Label[]; @@ -35,14 +36,11 @@ export async function createAgentConfigFile( clusterProperties: AgentConfigFileClusterProperties ): Promise { const asyncExecFile = promisify(execFile); - const agentDirectory = getAgentDirectoryOrThrow( - runtimeSettings.userDataDir, - clusterProperties.profileName + const { configFile, dataDirectory } = generateAgentConfigPaths( + runtimeSettings, + clusterProperties.rootClusterUri ); - const configFile = path.resolve(agentDirectory, 'config.yaml'); - const dataDirectory = path.resolve(agentDirectory, 'data'); - // remove the config file if exists try { await rm(configFile); @@ -69,6 +67,37 @@ export async function createAgentConfigFile( ); } +/** + * Returns agent config paths. + * @param runtimeSettings must not come from the renderer process. + * Otherwise, the generated paths may point outside the user's data directory. + * @param rootClusterUri may be passed from the renderer process. + */ +export function generateAgentConfigPaths( + runtimeSettings: RuntimeSettings, + rootClusterUri: RootClusterUri +): { + configFile: string; + dataDirectory: string; +} { + const parsed = routing.parseClusterUri(rootClusterUri); + if (!parsed?.params?.rootClusterId) { + throw new Error(`Incorrect root cluster URI: ${rootClusterUri}`); + } + + const agentDirectory = getAgentDirectoryOrThrow( + runtimeSettings.userDataDir, + parsed.params.rootClusterId + ); + const configFile = path.resolve(agentDirectory, 'config.yaml'); + const dataDirectory = path.resolve(agentDirectory, 'data'); + + return { + configFile, + dataDirectory, + }; +} + function getAgentDirectoryOrThrow( userDataDir: string, profileName: string @@ -81,7 +110,7 @@ function getAgentDirectoryOrThrow( path.dirname(resolved) === agentsDirectory && path.basename(resolved) === profileName; if (!isValidPath) { - throw new Error(`The agent config file path is incorrect: ${resolved}`); + throw new Error(`The agent config path is incorrect: ${resolved}`); } return resolved; } diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index d45a45833fe7e..3a7d18ca33a0d 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -20,6 +20,7 @@ import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mo // teleterm/services/config/index.ts reexports the config service client which depends on electron. // Importing electron breaks the fixtures if that's done from within storybook. import { createConfigService } from 'teleterm/services/config/configService'; +import { AgentProcessState } from 'teleterm/mainProcess/types'; export class MockMainProcessClient implements MainProcessClient { configService: ReturnType; @@ -37,7 +38,10 @@ export class MockMainProcessClient implements MainProcessClient { } getResolvedChildProcessAddresses = () => - Promise.resolve({ tsh: '', shared: '' }); + Promise.resolve({ + tsh: '', + shared: '', + }); openTerminalContextMenu() {} @@ -46,7 +50,10 @@ export class MockMainProcessClient implements MainProcessClient { openTabContextMenu() {} showFileSaveDialog() { - return Promise.resolve({ canceled: false, filePath: '' }); + return Promise.resolve({ + canceled: false, + filePath: '', + }); } fileStorage = createMockFileStorage(); @@ -84,6 +91,18 @@ export class MockMainProcessClient implements MainProcessClient { createAgentConfigFile() { return Promise.resolve(); } + + runAgent(): Promise { + return Promise.resolve(); + } + + getAgentState(): AgentProcessState { + return { status: 'not-started' }; + } + + subscribeToAgentUpdate() { + return { cleanup: () => undefined }; + } } export const makeRuntimeSettings = ( @@ -116,6 +135,7 @@ export const makeRuntimeSettings = ( arch: 'arm64', osVersion: '22.2.0', appVersion: '11.1.0', + isLocalBuild: runtimeSettings?.appVersion === '1.0.0-dev', username: 'alice', hostname: 'staging-mac-mini', ...runtimeSettings, diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 97c3275c9a5c5..4b919323eef14 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -29,13 +29,13 @@ import { nativeTheme, shell, } from 'electron'; -import { wait } from 'shared/utils/wait'; import { FileStorage, RuntimeSettings } from 'teleterm/types'; import { subscribeToFileStorageEvents } from 'teleterm/services/fileStorage'; import { LoggerColor, createFileLoggerService } from 'teleterm/services/logger'; import { ChildProcessAddresses } from 'teleterm/mainProcess/types'; import { getAssetPath } from 'teleterm/mainProcess/runtimeSettings'; +import { RootClusterUri } from 'teleterm/ui/uri'; import Logger from 'teleterm/logger'; import { @@ -49,6 +49,8 @@ import { resolveNetworkAddress } from './resolveNetworkAddress'; import { WindowsManager } from './windowsManager'; import { downloadAgent, FileDownloader } from './agentDownloader'; import { createAgentConfigFile } from './createAgentConfigFile'; +import { AgentRunner } from './agentRunner'; +import { terminateWithTimeout } from './terminateWithTimeout'; import type { AgentConfigFileClusterProperties } from './createAgentConfigFile'; @@ -79,6 +81,7 @@ export default class MainProcess { process.env ) ); + private readonly agentRunner: AgentRunner; private constructor(opts: Options) { this.settings = opts.settings; @@ -87,6 +90,20 @@ export default class MainProcess { this.appStateFileStorage = opts.appStateFileStorage; this.configFileStorage = opts.configFileStorage; this.windowsManager = opts.windowsManager; + this.agentRunner = new AgentRunner( + this.settings, + (rootClusterUri, state) => { + const window = this.windowsManager.getWindow(); + if (window.isDestroyed()) { + return; + } + window.webContents.send( + 'main-process-connect-my-computer-agent-update', + rootClusterUri, + state + ); + } + ); } static create(opts: Options) { @@ -95,18 +112,15 @@ export default class MainProcess { return instance; } - dispose() { - this.killTshdProcess(); - this.sharedProcess.kill('SIGTERM'); - const processesExit = Promise.all([ - promisifyProcessExit(this.tshdProcess), - promisifyProcessExit(this.sharedProcess), + async dispose(): Promise { + await Promise.all([ + // sending usage events on tshd shutdown has 10-seconds timeout + terminateWithTimeout(this.tshdProcess, 10_000, () => { + this.gracefullyKillTshdProcess(); + }), + terminateWithTimeout(this.sharedProcess), + this.agentRunner.killAll(), ]); - // sending usage events on tshd shutdown has 10 seconds timeout - const timeout = wait(10_000).then(() => - this.logger.error('Child process(es) did not exit within 10 seconds') - ); - return Promise.race([processesExit, timeout]); } private _init() { @@ -306,17 +320,42 @@ export default class MainProcess { ipcMain.handle('main-process-connect-my-computer-download-agent', () => this.downloadAgentShared() ); + ipcMain.handle( 'main-process-connect-my-computer-create-agent-config-file', (_, args: AgentConfigFileClusterProperties) => createAgentConfigFile(this.settings, { proxy: args.proxy, token: args.token, - profileName: args.profileName, + rootClusterUri: args.rootClusterUri, labels: args.labels, }) ); + ipcMain.handle( + 'main-process-connect-my-computer-run-agent', + async ( + _, + args: { + rootClusterUri: RootClusterUri; + } + ) => { + await this.agentRunner.start(args.rootClusterUri); + } + ); + + ipcMain.on( + 'main-process-connect-my-computer-get-agent-state', + ( + event, + args: { + rootClusterUri: RootClusterUri; + } + ) => { + event.returnValue = this.agentRunner.getState(args.rootClusterUri); + } + ); + subscribeToTerminalContextMenuEvent(); subscribeToTabContextMenuEvent(); subscribeToConfigServiceEvents(this.configService); @@ -392,7 +431,7 @@ export default class MainProcess { * kill a process is to send Ctrl-Break to its console. This task is done by * `tsh daemon stop` program. On Unix, the standard `SIGTERM` signal is sent. */ - private killTshdProcess() { + private gracefullyKillTshdProcess() { if (this.settings.platform !== 'win32') { this.tshdProcess.kill('SIGTERM'); return; @@ -421,10 +460,6 @@ function openDocsUrl() { shell.openExternal(DOCS_URL); } -function promisifyProcessExit(childProcess: ChildProcess) { - return new Promise(resolve => childProcess.once('exit', resolve)); -} - /** Shares promise returned from `promiseFn` across multiple concurrent callers. */ function sharePromise(promiseFn: () => Promise): () => Promise { let pending: Promise | undefined = undefined; diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 5865e1c198c6d..197300afb7f85 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -18,13 +18,19 @@ import { ipcRenderer } from 'electron'; import { createFileStorageClient } from 'teleterm/services/fileStorage'; import { AgentConfigFileClusterProperties } from 'teleterm/mainProcess/createAgentConfigFile'; +import { RootClusterUri } from 'teleterm/ui/uri'; import { createConfigServiceClient } from '../services/config'; import { openTerminalContextMenu } from './contextMenus/terminalContextMenu'; -import { MainProcessClient, ChildProcessAddresses } from './types'; import { openTabContextMenu } from './contextMenus/tabContextMenu'; +import { + MainProcessClient, + ChildProcessAddresses, + AgentProcessState, +} from './types'; + export default function createMainProcessClient(): MainProcessClient { return { getRuntimeSettings() { @@ -80,5 +86,33 @@ export default function createMainProcessClient(): MainProcessClient { clusterProperties ); }, + runAgent(clusterProperties: { rootClusterUri: RootClusterUri }) { + return ipcRenderer.invoke( + 'main-process-connect-my-computer-run-agent', + clusterProperties + ); + }, + getAgentState(clusterProperties: { rootClusterUri: RootClusterUri }) { + return ipcRenderer.sendSync( + 'main-process-connect-my-computer-get-agent-state', + clusterProperties + ); + }, + subscribeToAgentUpdate: (rootClusterUri, listener) => { + const onChange = ( + _, + eventRootClusterUri: RootClusterUri, + eventState: AgentProcessState + ) => { + if (eventRootClusterUri === rootClusterUri) { + listener(eventState); + } + }; + const channel = 'main-process-connect-my-computer-agent-update'; + ipcRenderer.addListener(channel, onChange); + return { + cleanup: () => ipcRenderer.removeListener(channel, onChange), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts index 987e760f69cc7..f7d09d046668e 100644 --- a/web/packages/teleterm/src/mainProcess/runtimeSettings.ts +++ b/web/packages/teleterm/src/mainProcess/runtimeSettings.ts @@ -82,6 +82,15 @@ function getRuntimeSettings(): RuntimeSettings { requestedNetworkAddress: tshdEventsAddress, }; + // To start the app in dev mode, we run `electron path_to_main.js`. It means + // that the app is run without package.json context, so it can not read the version + // from it. + // The way we run Electron can be changed (`electron .`), but it has one major + // drawback - dev app and bundled app will use the same app data directory. + // + // A workaround is to read the version from `process.env.npm_package_version`. + const appVersion = dev ? process.env.npm_package_version : app.getVersion(); + if (isInsecure) { tshd.flags.unshift('--debug'); tshd.flags.unshift('--insecure'); @@ -106,14 +115,8 @@ function getRuntimeSettings(): RuntimeSettings { ), arch: os.arch(), osVersion: os.release(), - // To start the app in dev mode we run `electron path_to_main.js`. It means - // that app is run without package.json context, so it can not read the version - // from it. - // The way we run Electron can be changed (`electron .`), but it has one major - // drawback - dev app and bundled app will use the same app data directory. - // - // A workaround is to read the version from `process.env.npm_package_version`. - appVersion: dev ? process.env.npm_package_version : app.getVersion(), + appVersion, + isLocalBuild: appVersion === '1.0.0-dev', username, hostname, }; diff --git a/web/packages/teleterm/src/mainProcess/terminateWithTimeout/index.ts b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/index.ts new file mode 100644 index 0000000000000..a7f317e0c541e --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './terminateWithTimeout'; diff --git a/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.test.ts b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.test.ts new file mode 100644 index 0000000000000..5fcf7110aeb9c --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.test.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fork } from 'node:child_process'; +import path from 'node:path'; + +import Logger, { NullService } from 'teleterm/logger'; + +import { terminateWithTimeout } from './terminateWithTimeout'; + +beforeAll(() => { + Logger.init(new NullService()); +}); + +test('kills a process gracefully when possible', async () => { + const process = fork(path.join(__dirname, 'testProcess.mjs'), { + silent: true, + }); + + await terminateWithTimeout(process); + + expect(process.killed).toBeTruthy(); + expect(process.signalCode).toBe('SIGTERM'); +}); + +test('kills a process using SIGKILL when a graceful kill did not work', async () => { + const process = fork( + path.join(__dirname, 'testProcess.mjs'), + ['ignore-sigterm'], + { + silent: true, + } + ); + + // wait for the process to start and register callbacks + await new Promise(resolve => process.stdout.once('data', resolve)); + + await terminateWithTimeout(process, 1_000); + + expect(process.killed).toBeTruthy(); + expect(process.signalCode).toBe('SIGKILL'); +}); + +test('killing a process that failed to start is noop', async () => { + const process = fork(path.join(__dirname, 'testProcess-nonExisting.mjs'), { + silent: true, + }); + jest.spyOn(process, 'kill'); + + // wait for the process + await new Promise(resolve => process.once('exit', resolve)); + await terminateWithTimeout(process, 1_000); + + expect(process.exitCode).toBe(1); + expect(process.signalCode).toBeNull(); + expect(process.kill).toHaveBeenCalledTimes(0); +}); + +test('killing a process that has been already killed is noop', async () => { + const process = fork(path.join(__dirname, 'testProcess.mjs'), { + silent: true, + }); + jest.spyOn(process, 'kill'); + + process.kill('SIGTERM'); + await new Promise(resolve => process.once('exit', resolve)); + expect(process.killed).toBeTruthy(); + expect(process.signalCode).toBe('SIGTERM'); + + await terminateWithTimeout(process, 1_000); + expect(process.kill).toHaveBeenCalledTimes(1); // called only once, in the test +}); diff --git a/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.ts b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.ts new file mode 100644 index 0000000000000..81a4908c614e9 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/terminateWithTimeout.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChildProcess } from 'node:child_process'; +import { setTimeout } from 'node:timers/promises'; + +import Logger from 'teleterm/logger'; + +const logger = new Logger('terminateWithTimeout'); + +/** + * Tries to kill a process in a graceful way - by sending a SIGTERM signal, or using + * {@link gracefullyKill} function if provided. + * If the process doesn't close within the specified {@link timeout}, a SIGKILL signal is sent. + */ +export async function terminateWithTimeout( + process: ChildProcess, + timeout = 5_000, + gracefullyKill: (process: ChildProcess) => void = process => + process.kill('SIGTERM') +): Promise { + if (!isProcessRunning(process)) { + logger.info( + `Process ${process.spawnfile} is not running. Nothing to kill.` + ); + return; + } + + const processExit = promisifyProcessExit(process); + + async function startKillingSequence(): Promise { + gracefullyKill(process); + + await setTimeout(timeout); + + if (isProcessRunning(process)) { + const timeoutInSeconds = timeout / 1_000; + logger.error( + `Process ${process.spawnfile} did not exit within ${timeoutInSeconds} seconds. Sending SIGKILL.` + ); + process.kill('SIGKILL'); + } + } + + startKillingSequence(); + + await processExit; +} + +function promisifyProcessExit(childProcess: ChildProcess): Promise { + return new Promise(resolve => childProcess.once('exit', resolve)); +} + +function isProcessRunning(process: ChildProcess): boolean { + return process.exitCode === null && process.signalCode === null; +} diff --git a/web/packages/teleterm/src/mainProcess/terminateWithTimeout/testProcess.mjs b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/testProcess.mjs new file mode 100644 index 0000000000000..b52c51a778787 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/terminateWithTimeout/testProcess.mjs @@ -0,0 +1,26 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import process from 'node:process'; +import { setTimeout } from 'node:timers/promises'; + +const ignoreSigterm = !!process.argv[2]; +if (ignoreSigterm) { + process.on('SIGTERM', () => {}); +} + +console.log('READY'); +await setTimeout(10_000); \ No newline at end of file diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index afaf4ff80cbcf..0c9e1b597d761 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -15,6 +15,8 @@ */ import { AgentConfigFileClusterProperties } from 'teleterm/mainProcess/createAgentConfigFile'; +import { RootClusterUri } from 'teleterm/ui/uri'; + import { Kind } from 'teleterm/ui/services/workspacesService'; import { FileStorage } from 'teleterm/services/fileStorage'; @@ -49,6 +51,11 @@ export type RuntimeSettings = { arch: string; osVersion: string; appVersion: string; + /** + * The {@link appVersion} is set to a real version only for packaged apps that went through our CI build pipeline. + * In local builds, both for the development version and for packaged apps, settings.appVersion is set to 1.0.0-dev. + */ + isLocalBuild: boolean; username: string; hostname: string; }; @@ -92,6 +99,16 @@ export type MainProcessClient = { createAgentConfigFile( properties: AgentConfigFileClusterProperties ): Promise; + runAgent(args: { rootClusterUri: RootClusterUri }): Promise; + getAgentState(args: { rootClusterUri: RootClusterUri }): AgentProcessState; + subscribeToAgentUpdate: SubscribeToAgentUpdate; +}; + +export type SubscribeToAgentUpdate = ( + rootClusterUri: RootClusterUri, + listener: (state: AgentProcessState) => void +) => { + cleanup: () => void; }; export type ChildProcessAddresses = { @@ -105,6 +122,26 @@ export type GrpcServerAddresses = ChildProcessAddresses & { export type Platform = NodeJS.Platform; +export type AgentProcessState = + | { + status: 'not-started'; + } + | { + status: 'running'; + } + | { + status: 'exited'; + code: number | null; + signal: NodeJS.Signals | null; + exitedSuccessfully: boolean; + /** Fragment of a stack trace when the process did not exit successfully. */ + stackTrace?: string; + } + | { + status: 'error'; + message: string; + }; + export interface ClusterContextMenuOptions { isClusterConnected: boolean; diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx index 25a53e80668d5..9d938dff6c4b4 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup.tsx @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, ButtonPrimary, Flex, Text } from 'design'; -import { useAsync } from 'shared/hooks/useAsync'; -import { wait } from 'shared/utils/wait'; +import { makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; import * as Alerts from 'design/Alert'; import { CircleCheck, CircleCross, CirclePlay, Spinner } from 'design/Icon'; @@ -26,12 +25,16 @@ import { useAppContext } from 'teleterm/ui/appContextProvider'; import Document from 'teleterm/ui/Document'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; import { retryWithRelogin } from 'teleterm/ui/utils'; +import { useConnectMyComputerContext } from 'teleterm/ui/ConnectMyComputer'; +import Logger from 'teleterm/logger'; interface DocumentConnectMyComputerSetupProps { visible: boolean; doc: types.DocumentConnectMyComputerSetup; } +const logger = new Logger('DocumentConnectMyComputerSetup'); + export function DocumentConnectMyComputerSetup( props: DocumentConnectMyComputerSetupProps ) { @@ -96,59 +99,90 @@ function Information(props: { onSetUpAgentClick(): void }) { function AgentSetup() { const ctx = useAppContext(); const { rootClusterUri } = useWorkspaceContext(); + const { runAgentAndWaitForNodeToJoin } = useConnectMyComputerContext(); const cluster = ctx.clustersService.findCluster(rootClusterUri); + const nodeToken = useRef(); - const [setUpRolesAttempt, runSetUpRolesAttempt] = useAsync( - useCallback(async () => { - retryWithRelogin(ctx, rootClusterUri, async () => { - let certsReloaded = false; + const [createRoleAttempt, runCreateRoleAttempt, setCreateRoleAttempt] = + useAsync( + useCallback(async () => { + retryWithRelogin(ctx, rootClusterUri, async () => { + let certsReloaded = false; - try { - const response = await ctx.connectMyComputerService.createRole( - rootClusterUri - ); - certsReloaded = response.certsReloaded; - } catch (error) { - if ((error.message as string)?.includes('access denied')) { - throw new Error( - 'Access denied. Contact your administrator for permissions to manage users and roles.' + try { + const response = await ctx.connectMyComputerService.createRole( + rootClusterUri ); + certsReloaded = response.certsReloaded; + } catch (error) { + if (isAccessDeniedError(error)) { + throw new Error( + 'Access denied. Contact your administrator for permissions to manage users and roles.' + ); + } + throw error; } - throw error; - } - // If tshd reloaded the certs to refresh the role list, the Electron app must resync details - // of the cluster to also update the role list in the UI. - if (certsReloaded) { - await ctx.clustersService.syncRootCluster(rootClusterUri); - } - }); - }, [ctx, rootClusterUri]) - ); - const [downloadAgentAttempt, runDownloadAgentAttempt] = useAsync( + // If tshd reloaded the certs to refresh the role list, the Electron app must resync details + // of the cluster to also update the role list in the UI. + if (certsReloaded) { + await ctx.clustersService.syncRootCluster(rootClusterUri); + } + }); + }, [ctx, rootClusterUri]) + ); + const [ + downloadAgentAttempt, + runDownloadAgentAttempt, + setDownloadAgentAttempt, + ] = useAsync( useCallback( () => ctx.connectMyComputerService.downloadAgent(), [ctx.connectMyComputerService] ) ); - const [generateConfigFileAttempt, runGenerateConfigFileAttempt] = useAsync( - useCallback( - () => - retryWithRelogin(ctx, rootClusterUri, () => - ctx.connectMyComputerService.createAgentConfigFile(cluster) - ), - [cluster, ctx, rootClusterUri] - ) - ); - const [joinClusterAttempt, runJoinClusterAttempt] = useAsync( - // TODO(gzdunek): delete node token after joining the cluster - useCallback(() => wait(1_000), []) + const [ + generateConfigFileAttempt, + runGenerateConfigFileAttempt, + setGenerateConfigFileAttempt, + ] = useAsync( + useCallback(async () => { + const { token } = await retryWithRelogin(ctx, rootClusterUri, () => + ctx.connectMyComputerService.createAgentConfigFile(cluster) + ); + nodeToken.current = token; + }, [cluster, ctx, rootClusterUri]) ); + const [joinClusterAttempt, runJoinClusterAttempt, setJoinClusterAttempt] = + useAsync( + useCallback(async () => { + if (!nodeToken.current) { + throw new Error('Node token is empty'); + } + await runAgentAndWaitForNodeToJoin(); + try { + await ctx.connectMyComputerService.deleteToken( + cluster.uri, + nodeToken.current + ); + } catch (error) { + // the user may not have permissions to remove the token, but it will expire in a few minutes anyway + if (isAccessDeniedError(error)) { + logger.error('Access denied when deleting a token.', error); + } + throw error; + } + }, [ + runAgentAndWaitForNodeToJoin, + ctx.connectMyComputerService, + cluster.uri, + ]) + ); const steps = [ { name: 'Setting up the role', - attempt: setUpRolesAttempt, + attempt: createRoleAttempt, }, { name: 'Downloading the agent', @@ -165,12 +199,16 @@ function AgentSetup() { ]; const runSteps = useCallback(async () => { - // uncomment when implemented + setCreateRoleAttempt(makeEmptyAttempt()); + setDownloadAgentAttempt(makeEmptyAttempt()); + setGenerateConfigFileAttempt(makeEmptyAttempt()); + setJoinClusterAttempt(makeEmptyAttempt()); + const actions = [ - runSetUpRolesAttempt, + runCreateRoleAttempt, runDownloadAgentAttempt, runGenerateConfigFileAttempt, - // runJoinClusterAttempt, + runJoinClusterAttempt, ]; for (const action of actions) { const [, error] = await action(); @@ -179,7 +217,11 @@ function AgentSetup() { } } }, [ - runSetUpRolesAttempt, + setCreateRoleAttempt, + setDownloadAgentAttempt, + setGenerateConfigFileAttempt, + setJoinClusterAttempt, + runCreateRoleAttempt, runDownloadAgentAttempt, runGenerateConfigFileAttempt, runJoinClusterAttempt, @@ -188,7 +230,7 @@ function AgentSetup() { useEffect(() => { if ( [ - setUpRolesAttempt, + createRoleAttempt, downloadAgentAttempt, generateConfigFileAttempt, joinClusterAttempt, @@ -200,7 +242,7 @@ function AgentSetup() { downloadAgentAttempt, generateConfigFileAttempt, joinClusterAttempt, - setUpRolesAttempt, + createRoleAttempt, runSteps, ]); @@ -241,7 +283,14 @@ function AgentSetup() {
  • {step.name} {step.attempt.status === 'error' && ( - {step.attempt.statusText} + + {step.attempt.statusText} + )}
  • @@ -254,3 +303,7 @@ function AgentSetup() { ); } + +function isAccessDeniedError(error: Error): boolean { + return (error.message as string)?.includes('access denied'); +} diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx new file mode 100644 index 0000000000000..86c863cb4f343 --- /dev/null +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.test.tsx @@ -0,0 +1,68 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from 'node:events'; + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { WorkspaceContextProvider } from 'teleterm/ui/Documents'; +import { AgentProcessState } from 'teleterm/mainProcess/types'; + +import { + ConnectMyComputerContextProvider, + useConnectMyComputerContext, +} from './connectMyComputerContext'; + +test('runAgentAndWaitForNodeToJoin re-throws errors that are thrown while spawning the process', async () => { + const mockedAppContext = new MockAppContext({}); + const eventEmitter = new EventEmitter(); + const errorStatus: AgentProcessState = { status: 'error', message: 'ENOENT' }; + jest + .spyOn(mockedAppContext.mainProcessClient, 'getAgentState') + .mockImplementation(() => errorStatus); + jest + .spyOn(mockedAppContext.connectMyComputerService, 'runAgent') + .mockImplementation(async () => { + // the error is emitted before the function resolves + eventEmitter.emit('', errorStatus); + return; + }); + jest + .spyOn(mockedAppContext.mainProcessClient, 'subscribeToAgentUpdate') + .mockImplementation((rootClusterUri, listener) => { + eventEmitter.on('', listener); + return { cleanup: () => eventEmitter.off('', listener) }; + }); + + const { result } = renderHook(() => useConnectMyComputerContext(), { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); + + await expect(result.current.runAgentAndWaitForNodeToJoin).rejects.toThrow( + `Agent process failed to start.\nENOENT` + ); +}); diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx new file mode 100644 index 0000000000000..91abf287f8295 --- /dev/null +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/connectMyComputerContext.tsx @@ -0,0 +1,165 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + useContext, + FC, + createContext, + useState, + useEffect, + useCallback, +} from 'react'; + +import { wait } from 'shared/utils/wait'; + +import { RootClusterUri } from 'teleterm/ui/uri'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +import type { + AgentProcessState, + MainProcessClient, +} from 'teleterm/mainProcess/types'; + +export interface ConnectMyComputerContext { + state: AgentProcessState; + runAgentAndWaitForNodeToJoin(): Promise; +} + +const ConnectMyComputerContext = createContext(null); + +export const ConnectMyComputerContextProvider: FC<{ + rootClusterUri: RootClusterUri; +}> = props => { + const { mainProcessClient, connectMyComputerService } = useAppContext(); + const [agentState, setAgentState] = useState( + () => + mainProcessClient.getAgentState({ + rootClusterUri: props.rootClusterUri, + }) || { + status: 'not-started', + } + ); + + const runAgentAndWaitForNodeToJoin = useCallback(async () => { + await connectMyComputerService.runAgent(props.rootClusterUri); + + // TODO(gzdunek): Replace with waiting for the node to join. + const waitForNodeToJoin = wait(1_000); + + await Promise.race([ + waitForNodeToJoin, + waitForAgentProcessErrors(mainProcessClient, props.rootClusterUri), + ]); + }, [connectMyComputerService, mainProcessClient, props.rootClusterUri]); + + useEffect(() => { + const { cleanup } = mainProcessClient.subscribeToAgentUpdate( + props.rootClusterUri, + state => setAgentState(state) + ); + return cleanup; + }, [mainProcessClient, props.rootClusterUri]); + + return ( + + ); +}; + +export const useConnectMyComputerContext = () => { + const context = useContext(ConnectMyComputerContext); + + if (!context) { + throw new Error( + 'ConnectMyComputerContext requires ConnectMyComputerContextProvider context.' + ); + } + + return context; +}; + +/** + * Waits for `error` and `exit` events from the agent process. + * If none of them happen within 20 seconds, the promise resolves. + */ +async function waitForAgentProcessErrors( + mainProcessClient: MainProcessClient, + rootClusterUri: RootClusterUri +) { + let cleanupFn: () => void; + + try { + const errorPromise = new Promise((_, reject) => { + const { cleanup } = mainProcessClient.subscribeToAgentUpdate( + rootClusterUri, + agentProcessState => { + const error = isProcessInErrorOrExitState(agentProcessState); + if (error) { + reject(error); + } + } + ); + + // the state may have changed before we started listening, we have to check the current state + const agentProcessState = mainProcessClient.getAgentState({ + rootClusterUri, + }); + const error = isProcessInErrorOrExitState(agentProcessState); + if (error) { + reject(error); + } + + cleanupFn = cleanup; + }); + await Promise.race([errorPromise, wait(20_000)]); + } finally { + cleanupFn(); + } +} + +function isProcessInErrorOrExitState( + agentProcessState: AgentProcessState +): Error | undefined { + if (agentProcessState.status === 'exited') { + const { code, signal } = agentProcessState; + const codeOrSignal = [ + // code can be 0, so we cannot just check it the same way as the signal. + code != null && `code ${code}`, + signal && `signal ${signal}`, + ] + .filter(Boolean) + .join(' '); + + return new Error( + [ + `Agent process exited with ${codeOrSignal}.`, + agentProcessState.stackTrace, + ] + .filter(Boolean) + .join('\n') + ); + } + if (agentProcessState.status === 'error') { + return new Error( + ['Agent process failed to start.', agentProcessState.message].join('\n') + ); + } +} diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/index.ts b/web/packages/teleterm/src/ui/ConnectMyComputer/index.ts index c01d682d018ee..e56a7adb3e84b 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/index.ts +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/index.ts @@ -15,4 +15,5 @@ limitations under the License. */ export * from './DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup'; +export * from './connectMyComputerContext'; export { NavigationMenu as ConnectMyComputerNavigationMenu } from './NavigationMenu'; diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index 22cd0fb4c4cca..61dcd161e16e7 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -32,7 +32,10 @@ import { import DocumentCluster from 'teleterm/ui/DocumentCluster'; import DocumentGateway from 'teleterm/ui/DocumentGateway'; import { DocumentTerminal } from 'teleterm/ui/DocumentTerminal'; -import { DocumentConnectMyComputerSetup } from 'teleterm/ui/ConnectMyComputer'; +import { + ConnectMyComputerContextProvider, + DocumentConnectMyComputerSetup, +} from 'teleterm/ui/ConnectMyComputer'; import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube'; import Document from 'teleterm/ui/Document'; @@ -76,11 +79,15 @@ export function DocumentsRenderer() { key={workspace.rootClusterUri} > - {workspace.documentsService.getDocuments().length ? ( - renderDocuments(workspace.documentsService) - ) : ( - - )} + + {workspace.documentsService.getDocuments().length ? ( + renderDocuments(workspace.documentsService) + ) : ( + + )} + ))} diff --git a/web/packages/teleterm/src/ui/services/connectMyComputer/connectMyComputerService.ts b/web/packages/teleterm/src/ui/services/connectMyComputer/connectMyComputerService.ts index 9b5947fc4bcf8..a482733a2b6b3 100644 --- a/web/packages/teleterm/src/ui/services/connectMyComputer/connectMyComputerService.ts +++ b/web/packages/teleterm/src/ui/services/connectMyComputer/connectMyComputerService.ts @@ -21,8 +21,6 @@ import { TshClient, } from 'teleterm/services/tshd/types'; -import { routing } from 'teleterm/ui/uri'; - import type * as uri from 'teleterm/ui/uri'; export class ConnectMyComputerService { @@ -41,17 +39,32 @@ export class ConnectMyComputerService { return this.tshClient.createConnectMyComputerRole(rootClusterUri); } - async createAgentConfigFile(cluster: Cluster): Promise { - const { rootClusterId } = routing.parseClusterUri(cluster.uri).params; - + async createAgentConfigFile(cluster: Cluster): Promise<{ + token: string; + }> { const { token, labelsList } = await this.tshClient.createConnectMyComputerNodeToken(cluster.uri); await this.mainProcessClient.createAgentConfigFile({ - profileName: rootClusterId, + rootClusterUri: cluster.uri, proxy: cluster.proxyHost, token: token, labels: labelsList, }); + + return { token }; + } + + runAgent(rootClusterUri: uri.RootClusterUri): Promise { + return this.mainProcessClient.runAgent({ + rootClusterUri, + }); + } + + deleteToken( + rootClusterUri: uri.RootClusterUri, + token: string + ): Promise { + return this.tshClient.deleteConnectMyComputerToken(rootClusterUri, token); } } diff --git a/yarn.lock b/yarn.lock index 45d1ca831afc0..1f16b489cc609 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4711,6 +4711,11 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -7252,7 +7257,7 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -escape-string-regexp@^1.0.5: +escape-string-regexp@^1.0.3, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= @@ -13110,6 +13115,19 @@ readable-stream@^2.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^2.0.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.0.6, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -13332,6 +13350,15 @@ repeat-string@^1.5.4, repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= +replacestream@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/replacestream/-/replacestream-4.0.3.tgz#3ee5798092be364b1cdb1484308492cb3dff2f36" + integrity sha512-AC0FiLS352pBBiZhd4VXB1Ab/lh0lEgpP+GGvZqbQh8a5cmXVoTe5EX/YeTFArnp4SRGTHh1qCHu9lGs1qG8sA== + dependencies: + escape-string-regexp "^1.0.3" + object-assign "^4.0.1" + readable-stream "^2.0.2" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -14338,6 +14365,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +strip-ansi-stream@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi-stream/-/strip-ansi-stream-2.0.1.tgz#9a5f4ef2f29a6e22e685bf69bf106df118230b46" + integrity sha512-8obaZwnoFRHCgxzrqil2435OBiCcJBOtcPkmCpgHCIJ6Rb3/Ewfob9HOkyhgxVAAaXnKGIWDiV8X4XJSOdKMkg== + dependencies: + ansi-regex "^6.0.1" + replacestream "^4.0.3" + strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"