Skip to content

Commit 4c38aab

Browse files
feat(ec2): dry run connection script to surface errors earlier. (#6037)
## Problem Follow up to #6018 (comment) ## Solution - Run `ssh` within the same env as it will be run on real connection. - Log any resulting errors, and inform user where the process failed. - Also part of this PR is moving some functions to `remoteSession.ts` that are general enough to be there. Error msg: <img width="1518" alt="Screenshot 2024-11-15 at 5 53 51 PM" src="https://github.com/user-attachments/assets/e8e56887-b792-43e3-9d94-73d0e5246766"> --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Justin M. Keyes <[email protected]>
1 parent e46500e commit 4c38aab

File tree

5 files changed

+173
-44
lines changed

5 files changed

+173
-44
lines changed

packages/core/src/awsService/ec2/model.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@ import { SsmClient } from '../../shared/clients/ssmClient'
1313
import { Ec2Client } from '../../shared/clients/ec2Client'
1414
import {
1515
VscodeRemoteConnection,
16+
createBoundProcess,
1617
ensureDependencies,
1718
getDeniedSsmActions,
1819
openRemoteTerminal,
1920
promptToAddInlinePolicy,
2021
} from '../../shared/remoteSession'
2122
import { DefaultIamClient } from '../../shared/clients/iamClient'
2223
import { ErrorInformation } from '../../shared/errors'
23-
import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../../shared/extensions/ssh'
24-
import { createBoundProcess } from '../../codecatalyst/model'
24+
import {
25+
sshAgentSocketVariable,
26+
SshError,
27+
startSshAgent,
28+
startVscodeRemote,
29+
testSshConnection,
30+
} from '../../shared/extensions/ssh'
2531
import { getLogger } from '../../shared/logger/logger'
2632
import { CancellationError, Timeout } from '../../shared/utilities/timeoutUtils'
2733
import { showMessageWithCancel } from '../../shared/utilities/messages'
@@ -149,13 +155,6 @@ export class Ec2Connecter implements vscode.Disposable {
149155
}
150156
}
151157

152-
public throwGeneralConnectionError(selection: Ec2Selection, error: Error) {
153-
this.throwConnectionError('Unable to connect to target instance. ', selection, {
154-
code: 'EC2SSMConnect',
155-
cause: error,
156-
})
157-
}
158-
159158
public async checkForStartSessionError(selection: Ec2Selection): Promise<void> {
160159
await this.checkForInstanceStatusError(selection)
161160

@@ -184,7 +183,7 @@ export class Ec2Connecter implements vscode.Disposable {
184183
const response = await this.ssmClient.startSession(selection.instanceId)
185184
await this.openSessionInTerminal(response, selection)
186185
} catch (err: unknown) {
187-
this.throwGeneralConnectionError(selection, err as Error)
186+
this.throwConnectionError('', selection, err as Error)
188187
}
189188
}
190189

@@ -193,11 +192,21 @@ export class Ec2Connecter implements vscode.Disposable {
193192

194193
const remoteUser = await this.getRemoteUser(selection.instanceId)
195194
const remoteEnv = await this.prepareEc2RemoteEnvWithProgress(selection, remoteUser)
196-
195+
const testSession = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession')
197196
try {
197+
await testSshConnection(
198+
remoteEnv.SessionProcess,
199+
remoteEnv.hostname,
200+
remoteEnv.sshPath,
201+
remoteUser,
202+
testSession
203+
)
198204
await startVscodeRemote(remoteEnv.SessionProcess, remoteEnv.hostname, '/', remoteEnv.vscPath, remoteUser)
199205
} catch (err) {
200-
this.throwGeneralConnectionError(selection, err as Error)
206+
const message = err instanceof SshError ? 'Testing SSH connection to instance failed' : ''
207+
this.throwConnectionError(message, selection, err as Error)
208+
} finally {
209+
await this.ssmClient.terminateSession(testSession)
201210
}
202211
}
203212

@@ -208,12 +217,19 @@ export class Ec2Connecter implements vscode.Disposable {
208217
return remoteEnv
209218
}
210219

220+
private async startSSMSession(instanceId: string): Promise<SSM.StartSessionResponse> {
221+
const ssmSession = await this.ssmClient.startSession(instanceId, 'AWS-StartSSHSession')
222+
await this.addActiveSession(instanceId, ssmSession.SessionId!)
223+
return ssmSession
224+
}
225+
211226
public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
212227
const logger = this.configureRemoteConnectionLogger(selection.instanceId)
213228
const { ssm, vsc, ssh } = (await ensureDependencies()).unwrap()
214229
const keyPair = await this.configureSshKeys(selection, remoteUser)
215-
const hostNamePrefix = 'aws-ec2-'
216-
const sshConfig = new SshConfig(ssh, hostNamePrefix, 'ec2_connect', keyPair.getPrivateKeyPath())
230+
const hostnamePrefix = 'aws-ec2-'
231+
const hostname = `${hostnamePrefix}${selection.instanceId}`
232+
const sshConfig = new SshConfig(ssh, hostnamePrefix, 'ec2_connect', keyPair.getPrivateKeyPath())
217233

218234
const config = await sshConfig.ensureValid()
219235
if (config.isErr()) {
@@ -222,8 +238,8 @@ export class Ec2Connecter implements vscode.Disposable {
222238

223239
throw err
224240
}
225-
const ssmSession = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession')
226-
await this.addActiveSession(selection.instanceId, ssmSession.SessionId!)
241+
242+
const ssmSession = await this.startSSMSession(selection.instanceId)
227243

228244
const vars = getEc2SsmEnv(selection, ssm, ssmSession)
229245
const envProvider = async () => {
@@ -236,7 +252,7 @@ export class Ec2Connecter implements vscode.Disposable {
236252
})
237253

238254
return {
239-
hostname: `${hostNamePrefix}${selection.instanceId}`,
255+
hostname,
240256
envProvider,
241257
sshPath: ssh,
242258
vscPath: vsc,

packages/core/src/codecatalyst/model.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { getLogger } from '../shared/logger'
1919
import { AsyncCollection, toCollection } from '../shared/utilities/asyncCollection'
2020
import { getCodeCatalystSpaceName, getCodeCatalystProjectName, getCodeCatalystDevEnvId } from '../shared/vscode/env'
2121
import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../shared/extensions/ssh'
22-
import { ChildProcess } from '../shared/utilities/processUtils'
2322
import { isDevenvVscode } from './utils'
2423
import { Timeout } from '../shared/utilities/timeoutUtils'
2524
import { Commands } from '../shared/vscode/commands2'
@@ -28,7 +27,7 @@ import { fileExists } from '../shared/filesystemUtilities'
2827
import { CodeCatalystAuthenticationProvider } from './auth'
2928
import { ToolkitError } from '../shared/errors'
3029
import { Result } from '../shared/utilities/result'
31-
import { VscodeRemoteConnection, ensureDependencies } from '../shared/remoteSession'
30+
import { EnvProvider, VscodeRemoteConnection, createBoundProcess, ensureDependencies } from '../shared/remoteSession'
3231
import { SshConfig, sshLogFileLocation } from '../shared/sshConfig'
3332
import { fs } from '../shared'
3433

@@ -111,28 +110,6 @@ export function createCodeCatalystEnvProvider(
111110
}
112111
}
113112

114-
type EnvProvider = () => Promise<NodeJS.ProcessEnv>
115-
116-
/**
117-
* Creates a new {@link ChildProcess} class bound to a specific dev environment. All instances of this
118-
* derived class will have SSM session information injected as environment variables as-needed.
119-
*/
120-
export function createBoundProcess(envProvider: EnvProvider): typeof ChildProcess {
121-
type Run = ChildProcess['run']
122-
return class SessionBoundProcess extends ChildProcess {
123-
public override async run(...args: Parameters<Run>): ReturnType<Run> {
124-
const options = args[0]
125-
const envVars = await envProvider()
126-
const spawnOptions = {
127-
...options?.spawnOptions,
128-
env: { ...envVars, ...options?.spawnOptions?.env },
129-
}
130-
131-
return super.run({ ...options, spawnOptions })
132-
}
133-
}
134-
}
135-
136113
export async function cacheBearerToken(bearerToken: string, devenvId: string): Promise<void> {
137114
await fs.writeFile(bearerTokenCacheLocation(devenvId), `${bearerToken}`, 'utf8')
138115
}

packages/core/src/shared/extensions/ssh.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,26 @@ import * as path from 'path'
88
import * as nls from 'vscode-nls'
99
import fs from '../fs/fs'
1010
import { getLogger } from '../logger'
11-
import { ChildProcess } from '../utilities/processUtils'
11+
import { ChildProcess, ChildProcessResult } from '../utilities/processUtils'
1212
import { ArrayConstructor, NonNullObject } from '../utilities/typeConstructors'
1313
import { Settings } from '../settings'
1414
import { VSCODE_EXTENSION_ID } from '../extensions'
15+
import { SSM } from 'aws-sdk'
16+
import { ErrorInformation, ToolkitError } from '../errors'
1517

1618
const localize = nls.loadMessageBundle()
1719

1820
export const sshAgentSocketVariable = 'SSH_AUTH_SOCK'
1921

22+
export class SshError extends ToolkitError {
23+
constructor(message: string, options: ErrorInformation) {
24+
super(message, {
25+
...options,
26+
code: SshError.name,
27+
})
28+
}
29+
}
30+
2031
export function getSshConfigPath(): string {
2132
const sshConfigDir = path.join(fs.getUserHomeDir(), '.ssh')
2233
return path.join(sshConfigDir, 'config')
@@ -119,6 +130,30 @@ export class RemoteSshSettings extends Settings.define('remote.SSH', remoteSshTy
119130
}
120131
}
121132

133+
export async function testSshConnection(
134+
ProcessClass: typeof ChildProcess,
135+
hostname: string,
136+
sshPath: string,
137+
user: string,
138+
session: SSM.StartSessionResponse
139+
): Promise<ChildProcessResult | never> {
140+
try {
141+
const env = { SESSION_ID: session.SessionId, STREAM_URL: session.StreamUrl, TOKEN: session.TokenValue }
142+
const result = await new ProcessClass(sshPath, [
143+
'-T',
144+
`${user}@${hostname}`,
145+
'echo "test connection succeeded" && exit',
146+
]).run({
147+
spawnOptions: {
148+
env,
149+
},
150+
})
151+
return result
152+
} catch (error) {
153+
throw new SshError('SSH connection test failed', { cause: error as Error })
154+
}
155+
}
156+
122157
export async function startVscodeRemote(
123158
ProcessClass: typeof ChildProcess,
124159
hostname: string,

packages/core/src/shared/remoteSession.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ interface DependencyPaths {
7777
readonly ssh: string
7878
}
7979

80-
type EnvProvider = () => Promise<NodeJS.ProcessEnv>
80+
export type EnvProvider = () => Promise<NodeJS.ProcessEnv>
8181

8282
export interface VscodeRemoteConnection {
8383
readonly sshPath: string
@@ -251,3 +251,23 @@ export async function getDeniedSsmActions(client: IamClient, roleArn: string): P
251251

252252
return deniedActions
253253
}
254+
255+
/**
256+
* Creates a new {@link ChildProcess} class bound to a specific remote environment. All instances of this
257+
* derived class will have SSM session information injected as environment variables as-needed.
258+
*/
259+
export function createBoundProcess(envProvider: EnvProvider): typeof ChildProcess {
260+
type Run = ChildProcess['run']
261+
return class SessionBoundProcess extends ChildProcess {
262+
public override async run(...args: Parameters<Run>): ReturnType<Run> {
263+
const options = args[0]
264+
const envVars = await envProvider()
265+
const spawnOptions = {
266+
...options?.spawnOptions,
267+
env: { ...envVars, ...options?.spawnOptions?.env },
268+
}
269+
270+
return super.run({ ...options, spawnOptions })
271+
}
272+
}
273+
}

packages/core/src/test/shared/extensions/ssh.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
*/
55
import * as assert from 'assert'
66
import { ChildProcess } from '../../../shared/utilities/processUtils'
7-
import { startSshAgent } from '../../../shared/extensions/ssh'
7+
import { startSshAgent, testSshConnection } from '../../../shared/extensions/ssh'
8+
import { createBoundProcess } from '../../../shared/remoteSession'
9+
import { createExecutableFile, createTestWorkspaceFolder } from '../../testUtil'
10+
import { WorkspaceFolder } from 'vscode'
11+
import path from 'path'
12+
import { SSM } from 'aws-sdk'
13+
import { fs } from '../../../shared/fs/fs'
14+
import { isWin } from '../../../shared/vscode/env'
815

916
describe('SSH Agent', function () {
1017
it('can start the agent on windows', async function () {
@@ -29,3 +36,77 @@ describe('SSH Agent', function () {
2936
assert.strictEqual(await getStatus(), 'Running')
3037
})
3138
})
39+
40+
function echoEnvVarsCmd(varNames: string[]) {
41+
const toShell = (s: string) => (isWin() ? `%${s}%` : `$${s}`)
42+
return `echo "${varNames.map(toShell).join(' ')}"`
43+
}
44+
45+
/**
46+
* Trim noisy windows ChildProcess result to final line for easier testing.
47+
*/
48+
function assertOutputContains(rawOutput: string, expectedString: string): void | never {
49+
const output = rawOutput.trim().split('\n').at(-1)?.replace('"', '') ?? ''
50+
assert.ok(output.includes(expectedString), `Expected output to contain "${expectedString}", but got "${output}"`)
51+
}
52+
53+
describe('testSshConnection', function () {
54+
let testWorkspace: WorkspaceFolder
55+
let sshPath: string
56+
57+
before(async function () {
58+
testWorkspace = await createTestWorkspaceFolder()
59+
sshPath = path.join(testWorkspace.uri.fsPath, `fakeSSH${isWin() ? '.cmd' : ''}`)
60+
})
61+
62+
after(async function () {
63+
await fs.delete(testWorkspace.uri.fsPath, { recursive: true, force: true })
64+
await fs.delete(sshPath, { force: true })
65+
})
66+
67+
it('runs in bound process', async function () {
68+
const envProvider = async () => ({ MY_VAR: 'yes' })
69+
const process = createBoundProcess(envProvider)
70+
const session = {
71+
SessionId: 'testSession',
72+
StreamUrl: 'testUrl',
73+
TokenValue: 'testToken',
74+
} as SSM.StartSessionResponse
75+
76+
await createExecutableFile(sshPath, echoEnvVarsCmd(['MY_VAR']))
77+
const r = await testSshConnection(process, 'localhost', sshPath, 'test-user', session)
78+
assertOutputContains(r.stdout, 'yes')
79+
})
80+
81+
it('injects new session into env', async function () {
82+
const oldSession = {
83+
SessionId: 'testSession1',
84+
StreamUrl: 'testUrl1',
85+
TokenValue: 'testToken1',
86+
} as SSM.StartSessionResponse
87+
const newSession = {
88+
SessionId: 'testSession2',
89+
StreamUrl: 'testUrl2',
90+
TokenValue: 'testToken2',
91+
} as SSM.StartSessionResponse
92+
const envProvider = async () => ({
93+
SESSION_ID: oldSession.SessionId,
94+
STREAM_URL: oldSession.StreamUrl,
95+
TOKEN: oldSession.TokenValue,
96+
})
97+
const process = createBoundProcess(envProvider)
98+
99+
await createExecutableFile(sshPath, echoEnvVarsCmd(['SESSION_ID', 'STREAM_URL', 'TOKEN']))
100+
const r = await testSshConnection(process, 'localhost', sshPath, 'test-user', newSession)
101+
assertOutputContains(r.stdout, `${newSession.SessionId} ${newSession.StreamUrl} ${newSession.TokenValue}`)
102+
})
103+
104+
it('passes proper args to the ssh invoke', async function () {
105+
const executableFileContent = isWin() ? `echo "%1 %2"` : `echo "$1 $2"`
106+
const process = createBoundProcess(async () => ({}))
107+
await createExecutableFile(sshPath, executableFileContent)
108+
const r = await testSshConnection(process, 'localhost', sshPath, 'test-user', {} as SSM.StartSessionResponse)
109+
assertOutputContains(r.stdout, '-T')
110+
assertOutputContains(r.stdout, 'test-user@localhost')
111+
})
112+
})

0 commit comments

Comments
 (0)