Skip to content

Commit d28f983

Browse files
ec2: connect to terminal w/o error handling. (#3575)
* add empty connect command * create sample QuickPick on click * hacky prompt + func to list instanceId * very hacky way to create prompt with instanceIds * move extractInstanceIds to own function * utilize lastTouchedRegion in prompt * throw cancellationError on user cancel * add a title to quickpick * refactor: increase testability * move test utility func to shared utility file * add general test cases for extractInstanceIdsFromReservations * add basic test for prompter * configure command to devMode * refactor to utilize wizard + integrate regionSubmenu * add new testing file * refactor to utilize regionSubmenu in isolation, rather than wrapped in Wizard class * fix awkward indent * add test file from feature/cwl branch * remove old prompter old + tests * delete tests that rely on feature/cwl changes * refactor to avoid circular dependency * fix improper headers + imports * introduce Ec2ConnectClient to handle connection * remove dead parameter * close connection on terminal close * add a little more to error msg * remove dead imports * log error to console * rename function Co-authored-by: Justin M. Keyes <[email protected]> * fix header Co-authored-by: Justin M. Keyes <[email protected]> * fix headers and imports * remove year from header * delete outdated test case * delete outdated test file * remove year from copyright header * fix formatting of files * remove alias in Ec2Instance * utilize interface for object-like shape * move code to general Ec2Client * change Log Groups to selection in region submenu * refactor to avoid circular dependency * fix formatting * fix formatting * comment out the ec2Client we are not using * style fix * generalize some code to a remoteSession file * add space between pieces * remove dead import * refactor to avoid circular dependency * change callback to async/await * remove onError parameter and utilize .catch instead * make error handling a try-catch * avoid unnecessary default prefix on client name Co-authored-by: Justin M. Keyes <[email protected]> * fix old import * refactor defaultEc2client -> ec2Client * rename Ec2ConnectClient --------- Co-authored-by: Justin M. Keyes <[email protected]>
1 parent 3d6e3c4 commit d28f983

File tree

10 files changed

+156
-48
lines changed

10 files changed

+156
-48
lines changed

src/ec2/activation.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { tryConnect } from './commands'
99
export async function activate(ctx: ExtContext): Promise<void> {
1010
ctx.extensionContext.subscriptions.push(
1111
Commands.register('aws.ec2.connectToInstance', async (param?: unknown) => {
12-
console.log('You just ran the aws.ec2.connectToInstance command!')
1312
tryConnect()
1413
})
1514
)

src/ec2/commands.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { createEC2ConnectPrompter, handleEc2ConnectPrompterResponse } from './prompter'
6+
import { createEc2ConnectPrompter, handleEc2ConnectPrompterResponse } from './prompter'
77
import { isValidResponse } from '../shared/wizards/wizard'
8+
import { Ec2ConnectionManager } from './model'
89

910
export async function tryConnect(): Promise<void> {
10-
const prompter = createEC2ConnectPrompter()
11+
const prompter = createEc2ConnectPrompter()
1112
const response = await prompter.prompt()
1213

1314
if (isValidResponse(response)) {
1415
const selection = handleEc2ConnectPrompterResponse(response)
15-
console.log(selection)
16+
const ec2Client = new Ec2ConnectionManager(selection.region)
17+
await ec2Client.attemptEc2Connection(selection)
1618
}
1719
}

src/ec2/model.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
import { Session } from 'aws-sdk/clients/ssm'
7+
import { Ec2Selection } from './utils'
8+
import { getOrInstallCli } from '../shared/utilities/cliUtils'
9+
import { isCloud9 } from '../shared/extensionUtilities'
10+
import { ToolkitError, isAwsError } from '../shared/errors'
11+
import { SsmClient } from '../shared/clients/ssmClient'
12+
import { openRemoteTerminal } from '../shared/remoteSession'
13+
14+
export class Ec2ConnectionManager {
15+
// Will need the ec2Client for probing errors,
16+
private ssmClient: SsmClient
17+
//private ec2Client: DefaultEc2Client
18+
19+
public constructor(readonly regionCode: string) {
20+
this.ssmClient = new SsmClient(this.regionCode)
21+
//this.ec2Client = new DefaultEc2Client(this.regionCode)
22+
}
23+
24+
private async handleStartSessionError(err: AWS.AWSError): Promise<void> {
25+
const failureMessage =
26+
"SSM: Failed to start session with target instance. Common reasons include: \n 1. SSM Agent not installed on instance. \n 2. The required IAM instance profile isn't attached to the instance. \n 3. Session manager setup is incomplete."
27+
await vscode.window.showErrorMessage(failureMessage)
28+
29+
throw new ToolkitError('Start Session Failed. ')
30+
}
31+
32+
private async openSessionInTerminal(session: Session, selection: Ec2Selection) {
33+
const ssmPlugin = await getOrInstallCli('session-manager-plugin', !isCloud9)
34+
const shellArgs = [JSON.stringify(session), selection.region, 'StartSession']
35+
const terminalOptions = {
36+
name: selection.region + '/' + selection.instanceId,
37+
shellPath: ssmPlugin,
38+
shellArgs: shellArgs,
39+
}
40+
41+
await openRemoteTerminal(terminalOptions, () => this.ssmClient.terminateSession(session)).catch(err => {
42+
throw ToolkitError.chain(err, 'Failed to open ec2 instance.')
43+
})
44+
}
45+
46+
public async attemptEc2Connection(selection: Ec2Selection): Promise<void> {
47+
try {
48+
const response = await this.ssmClient.startSession(selection.instanceId)
49+
await this.openSessionInTerminal(response, selection)
50+
} catch (err) {
51+
if (isAwsError(err)) {
52+
await this.handleStartSessionError(err)
53+
} else {
54+
throw err
55+
}
56+
}
57+
}
58+
}

src/ec2/prompter.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,24 @@
44
*/
55

66
import { RegionSubmenu, RegionSubmenuResponse } from '../shared/ui/common/regionSubmenu'
7-
import { getInstanceIdsFromRegion } from './utils'
7+
import { Ec2Selection, getInstanceIdsFromRegion } from './utils'
88
import { DataQuickPickItem } from '../shared/ui/pickerPrompter'
99

10-
interface EC2Selection {
11-
instanceId: string
12-
region: string
13-
}
14-
1510
function asQuickpickItem(instanceId: string): DataQuickPickItem<string> {
1611
return {
1712
label: instanceId,
1813
data: instanceId,
1914
}
2015
}
2116

22-
export function handleEc2ConnectPrompterResponse(response: RegionSubmenuResponse<string>): EC2Selection {
17+
export function handleEc2ConnectPrompterResponse(response: RegionSubmenuResponse<string>): Ec2Selection {
2318
return {
2419
instanceId: response.data,
2520
region: response.region,
2621
}
2722
}
2823

29-
export function createEC2ConnectPrompter(): RegionSubmenu<string> {
24+
export function createEc2ConnectPrompter(): RegionSubmenu<string> {
3025
return new RegionSubmenu(
3126
async region => (await getInstanceIdsFromRegion(region)).map(asQuickpickItem).promise(),
3227
{ title: 'Select EC2 Instance Id' },

src/ec2/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
*/
55

66
import { AsyncCollection } from '../shared/utilities/asyncCollection'
7-
import { DefaultEc2Client } from '../shared/clients/ec2Client'
7+
import { Ec2Client } from '../shared/clients/ec2Client'
8+
9+
export interface Ec2Selection {
10+
instanceId: string
11+
region: string
12+
}
813

914
export async function getInstanceIdsFromRegion(regionCode: string): Promise<AsyncCollection<string>> {
10-
const client = new DefaultEc2Client(regionCode)
15+
const client = new Ec2Client(regionCode)
1116
return client.getInstanceIds()
1217
}

src/ecs/commands.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as vscode from 'vscode'
1111
import { DefaultIamClient } from '../shared/clients/iamClient'
1212
import { INSIGHTS_TIMESTAMP_FORMAT } from '../shared/constants'
1313
import globals from '../shared/extensionGlobals'
14-
import { PromptSettings, Settings } from '../shared/settings'
14+
import { PromptSettings } from '../shared/settings'
1515
import { ChildProcess } from '../shared/utilities/childProcess'
1616
import { showMessageWithCancel, showOutputMessage } from '../shared/utilities/messages'
1717
import { removeAnsi } from '../shared/utilities/textUtilities'
@@ -24,6 +24,7 @@ import { getResourceFromTreeNode } from '../shared/treeview/utils'
2424
import { Container, Service } from './model'
2525
import { Instance } from '../shared/utilities/typeConstructors'
2626
import { telemetry } from '../shared/telemetry/telemetry'
27+
import { openRemoteTerminal } from '../shared/remoteSession'
2728

2829
async function runCommandWizard(
2930
param?: unknown,
@@ -136,44 +137,22 @@ export const runCommandInContainer = Commands.register('aws.ecs.runCommandInCont
136137
})
137138
})
138139

139-
// VSC is logging args to the PTY host log file if shell integration is enabled :(
140-
async function withoutShellIntegration<T>(cb: () => T | Promise<T>): Promise<T> {
141-
const userValue = Settings.instance.get('terminal.integrated.shellIntegration.enabled', Boolean)
142-
143-
try {
144-
await Settings.instance.update('terminal.integrated.shellIntegration.enabled', false)
145-
return await cb()
146-
} finally {
147-
Settings.instance.update('terminal.integrated.shellIntegration.enabled', userValue)
148-
}
149-
}
150-
151140
export const openTaskInTerminal = Commands.register('aws.ecs.openTaskInTerminal', (obj?: unknown) => {
152141
return telemetry.ecs_runExecuteCommand.run(async span => {
153142
span.record({ ecsExecuteCommandType: 'shell' })
154143

155144
const startCommand = new EcsSettings().get('openTerminalCommand')
156145
const { container, task, command } = await runCommandWizard(obj, startCommand)
146+
const session = await container.prepareCommandForTask(command, task)
157147

158-
try {
159-
const session = await container.prepareCommandForTask(command, task)
160-
await withoutShellIntegration(() => {
161-
const terminal = vscode.window.createTerminal({
162-
name: `${container.description.name}/${task}`,
163-
shellPath: session.path,
164-
shellArgs: session.args,
165-
})
166-
167-
const listener = vscode.window.onDidCloseTerminal(t => {
168-
if (t.processId === terminal.processId) {
169-
vscode.Disposable.from(listener, session).dispose()
170-
}
171-
})
148+
const terminalOptions = {
149+
name: `${container.description.name}/${task}`,
150+
shellPath: session.path,
151+
shellArgs: session.args,
152+
}
172153

173-
terminal.show()
174-
})
175-
} catch (err) {
154+
await openRemoteTerminal(terminalOptions, session.dispose).catch(err => {
176155
throw ToolkitError.chain(err, localize('AWS.ecs.openTaskInTerminal.error', 'Failed to open terminal.'))
177-
}
156+
})
178157
})
179158
})

src/shared/clients/ec2Client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import globals from '../extensionGlobals'
88
import { AsyncCollection } from '../utilities/asyncCollection'
99
import { pageableToCollection } from '../utilities/collectionUtils'
1010

11-
export class DefaultEc2Client {
11+
export class Ec2Client {
1212
public constructor(public readonly regionCode: string) {}
1313

1414
private async createSdkClient(): Promise<EC2> {

src/shared/clients/ssmClient.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { AWSError, SSM } from 'aws-sdk'
7+
import globals from '../extensionGlobals'
8+
import { PromiseResult } from 'aws-sdk/lib/request'
9+
import { getLogger } from '../logger/logger'
10+
11+
export class SsmClient {
12+
public constructor(public readonly regionCode: string) {}
13+
14+
private async createSdkClient(): Promise<SSM> {
15+
return await globals.sdkClientBuilder.createAwsService(SSM, undefined, this.regionCode)
16+
}
17+
18+
public async terminateSession(
19+
session: SSM.Session
20+
): Promise<void | PromiseResult<SSM.TerminateSessionResponse, AWSError>> {
21+
const sessionId = session.SessionId!
22+
const client = await this.createSdkClient()
23+
const termination = await client
24+
.terminateSession({ SessionId: sessionId })
25+
.promise()
26+
.catch(err => {
27+
getLogger().warn(`ssm: failed to terminate session "${sessionId}": %s`, err)
28+
})
29+
return termination
30+
}
31+
32+
public async startSession(target: string): Promise<PromiseResult<SSM.StartSessionResponse, AWSError>> {
33+
const client = await this.createSdkClient()
34+
const response = await client.startSession({ Target: target }).promise()
35+
return response
36+
}
37+
}

src/shared/remoteSession.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { Settings } from '../shared/settings'
8+
9+
export async function openRemoteTerminal(options: vscode.TerminalOptions, onClose: () => void) {
10+
await withoutShellIntegration(() => {
11+
const terminal = vscode.window.createTerminal(options)
12+
13+
const listener = vscode.window.onDidCloseTerminal(t => {
14+
if (t.processId === terminal.processId) {
15+
vscode.Disposable.from(listener, { dispose: onClose }).dispose()
16+
}
17+
})
18+
19+
terminal.show()
20+
})
21+
}
22+
23+
// VSC is logging args to the PTY host log file if shell integration is enabled :(
24+
async function withoutShellIntegration<T>(cb: () => T | Promise<T>): Promise<T> {
25+
const userValue = Settings.instance.get('terminal.integrated.shellIntegration.enabled', Boolean)
26+
27+
try {
28+
await Settings.instance.update('terminal.integrated.shellIntegration.enabled', false)
29+
return await cb()
30+
} finally {
31+
Settings.instance.update('terminal.integrated.shellIntegration.enabled', userValue)
32+
}
33+
}

src/test/shared/clients/defaultEc2Client.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import { AsyncCollection } from '../../../shared/utilities/asyncCollection'
88
import { EC2 } from 'aws-sdk'
99
import { toCollection } from '../../../shared/utilities/asyncCollection'
1010
import { intoCollection } from '../../../shared/utilities/collectionUtils'
11-
import { DefaultEc2Client } from '../../../shared/clients/ec2Client'
11+
import { Ec2Client } from '../../../shared/clients/ec2Client'
1212

1313
describe('extractInstanceIdsFromReservations', function () {
14-
const client = new DefaultEc2Client('')
14+
const client = new Ec2Client('')
1515
it('returns empty when given empty collection', async function () {
1616
const actualResult = await client
1717
.extractInstanceIdsFromReservations(

0 commit comments

Comments
 (0)