forked from gravitational/teleport
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Connect My Computer: Join cluster (gravitational#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
- Loading branch information
Showing
25 changed files
with
1,194 additions
and
133 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
}); |
179 changes: 179 additions & 0 deletions
179
web/packages/teleterm/src/mainProcess/agentRunner/agentRunner.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} |
Oops, something went wrong.