Connect My Computer: Join cluster (#29479)

* Add `generateAgentConfigPaths` function that creates config path based on `runtimeSettings` and `rootClusterUri`. Pass `rootClusterUri` instead of `profileName` `createAgentFile`

* Add functions to run agent and subscribe to its events

* Clear attempts when restarting the process

* Run the agent from the UI and remove node token

* Show errors from the process in the setup UI

* Refactor reporting errors from the agent process

* Add `isLocalBuild`

* Join arguments with space when logging

* Add `killProcess` function that handles process closing

* Spawn a real process in `agentRunner` tests

* Keep `agentRunner` files in a single directory

* Catch errors from `deleteToken`

* Remove `env: process.env`

* Match on "access denied" when checking error from `deleteToken`

* Reject when an agent process fails to start in test

* Match only on "ENOENT"

* Correct test name ("SIGTERM" -> "SIGKILL")

* Test terminating the process and then trying to kill it

* Wait for "exit" event instead of "close"

* Rename `killProcess` to `terminateWithTimeout`

* Add `getAgentState` method to synchronously get the agent state

* Remove space before new line

* Simplify the logic in `AgentRunner`

* Fix TS error

* Do not send agent updates to a destroyed window

* Add logging cluster URI and updated state

* Catch errors that are thrown while spawning the process

* Strip ANSI codes

* Add `exitedSuccessfully` property to `exited` state, so we won't have to check signal and code every time

* Move `strip-ansi-stream` to `dependencies`

* Fix license
This commit is contained in:
Grzegorz Zdunek
2023-08-03 09:26:34 +00:00
committed by GitHub
parent da500b8a8b
commit cc681c91ed
25 changed files with 1194 additions and 133 deletions
+3
View File
@@ -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: ['<rootDir>/web/packages/shared/setupTests.tsx'],
};
+1
View File
@@ -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": {
@@ -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<string, any>
): Promise<void> {
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<string, any>
): Promise<string> {
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;
@@ -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();
}
});
@@ -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<ChildProcess> {
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<void> {
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<void> {
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);
}
@@ -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);
@@ -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';
@@ -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');
});
@@ -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<void> {
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;
}
@@ -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<typeof createConfigService>;
@@ -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<void> {
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,
@@ -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<void> {
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<T>(promiseFn: () => Promise<T>): () => Promise<T> {
let pending: Promise<T> | undefined = undefined;
@@ -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),
};
},
};
}
@@ -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,
};
@@ -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';
@@ -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
});
@@ -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<void> {
if (!isProcessRunning(process)) {
logger.info(
`Process ${process.spawnfile} is not running. Nothing to kill.`
);
return;
}
const processExit = promisifyProcessExit(process);
async function startKillingSequence(): Promise<void> {
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<void> {
return new Promise(resolve => childProcess.once('exit', resolve));
}
function isProcessRunning(process: ChildProcess): boolean {
return process.exitCode === null && process.signalCode === null;
}
@@ -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);
@@ -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<void>;
runAgent(args: { rootClusterUri: RootClusterUri }): Promise<void>;
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;
@@ -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<string>();
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() {
<li>
{step.name}
{step.attempt.status === 'error' && (
<Alerts.Danger mb={0}>{step.attempt.statusText}</Alerts.Danger>
<Alerts.Danger
mb={0}
css={`
white-space: pre-wrap;
`}
>
{step.attempt.statusText}
</Alerts.Danger>
)}
</li>
</Flex>
@@ -254,3 +303,7 @@ function AgentSetup() {
</>
);
}
function isAccessDeniedError(error: Error): boolean {
return (error.message as string)?.includes('access denied');
}
@@ -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 }) => (
<MockAppContextProvider appContext={mockedAppContext}>
<WorkspaceContextProvider value={null}>
<ConnectMyComputerContextProvider rootClusterUri={'/clusters/a'}>
{children}
</ConnectMyComputerContextProvider>
</WorkspaceContextProvider>
</MockAppContextProvider>
),
});
await expect(result.current.runAgentAndWaitForNodeToJoin).rejects.toThrow(
`Agent process failed to start.\nENOENT`
);
});
@@ -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<void>;
}
const ConnectMyComputerContext = createContext<ConnectMyComputerContext>(null);
export const ConnectMyComputerContextProvider: FC<{
rootClusterUri: RootClusterUri;
}> = props => {
const { mainProcessClient, connectMyComputerService } = useAppContext();
const [agentState, setAgentState] = useState<AgentProcessState>(
() =>
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 (
<ConnectMyComputerContext.Provider
value={{
state: agentState,
runAgentAndWaitForNodeToJoin,
}}
children={props.children}
/>
);
};
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')
);
}
}
@@ -15,4 +15,5 @@ limitations under the License.
*/
export * from './DocumentConnectMyComputerSetup/DocumentConnectMyComputerSetup';
export * from './connectMyComputerContext';
export { NavigationMenu as ConnectMyComputerNavigationMenu } from './NavigationMenu';
@@ -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}
>
<WorkspaceContextProvider value={workspace}>
{workspace.documentsService.getDocuments().length ? (
renderDocuments(workspace.documentsService)
) : (
<KeyboardShortcutsPanel />
)}
<ConnectMyComputerContextProvider
rootClusterUri={workspace.rootClusterUri}
>
{workspace.documentsService.getDocuments().length ? (
renderDocuments(workspace.documentsService)
) : (
<KeyboardShortcutsPanel />
)}
</ConnectMyComputerContextProvider>
</WorkspaceContextProvider>
</DocumentsContainer>
))}
@@ -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<void> {
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<void> {
return this.mainProcessClient.runAgent({
rootClusterUri,
});
}
deleteToken(
rootClusterUri: uri.RootClusterUri,
token: string
): Promise<void> {
return this.tshClient.deleteConnectMyComputerToken(rootClusterUri, token);
}
}
+36 -1
View File
@@ -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"