mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
+85
@@ -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;
|
||||
|
||||
|
||||
+100
-47
@@ -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>
|
||||
))}
|
||||
|
||||
+19
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user