Skip to content

Commit 142761e

Browse files
HweinstockJadenSimonjustinmk3Weinstock
authored
feat(ec2): try to dispose SSM session state #5616
## Problem The remote connection through VSCode does not terminate the SSM session on close consistently. Note: We do not get an event when the remote window is closed. ## Solution Leverage two best-effort approaches: - only allow a single connection from the toolkit to any given EC2 Instance. If a customer attempts to open another remote window in an EC2 instance, we can use that as a sign to terminate the old session. - on toolkit shutdown (deactivate), remote any sessions that are still running. ## Implementation Details - Implement `Ec2RemoteEnvManager` to manage the remote environments. Behaves like a map of instance ids to sessions ids that most importantly maintains the invariant that any deleted item has its session terminated. - Refactor `packages/core/src/awsService/ec2/commands.ts` and `packages/core/src/awsService/ec2/activation.ts` to allow for state tracking in `EC2ConnectionManager`. This change also gives us an opportunity to improve the testing infrastructure for this code. --- Co-authored-by: JadenSimon <[email protected]> Co-authored-by: Justin M. Keyes <[email protected]> Co-authored-by: Weinstock <[email protected]>
1 parent 7dabb7c commit 142761e

File tree

17 files changed

+372
-61
lines changed

17 files changed

+372
-61
lines changed

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as vscode from 'vscode'
66
import { ExtContext } from '../../shared/extensions'
77
import { Commands } from '../../shared/vscode/commands2'
88
import { telemetry } from '../../shared/telemetry/telemetry'
9-
import { Ec2InstanceNode } from './explorer/ec2InstanceNode'
9+
import { Ec2InstanceNode, tryRefreshNode } from './explorer/ec2InstanceNode'
1010
import { copyTextCommand } from '../../awsexplorer/commands/copyText'
1111
import { Ec2Node } from './explorer/ec2ParentNode'
1212
import {
@@ -15,13 +15,15 @@ import {
1515
rebootInstance,
1616
startInstance,
1717
stopInstance,
18-
refreshExplorer,
19-
openLogDocument,
2018
linkToLaunchInstance,
19+
openLogDocument,
2120
} from './commands'
21+
import { Ec2ConnecterMap } from './connectionManagerMap'
2222
import { ec2LogsScheme } from '../../shared/constants'
2323
import { Ec2LogDocumentProvider } from './ec2LogDocumentProvider'
2424

25+
const connectionManagers = new Ec2ConnecterMap()
26+
2527
export async function activate(ctx: ExtContext): Promise<void> {
2628
ctx.extensionContext.subscriptions.push(
2729
vscode.workspace.registerTextDocumentContentProvider(ec2LogsScheme, new Ec2LogDocumentProvider())
@@ -30,7 +32,7 @@ export async function activate(ctx: ExtContext): Promise<void> {
3032
Commands.register('aws.ec2.openTerminal', async (node?: Ec2InstanceNode) => {
3133
await telemetry.ec2_connectToInstance.run(async (span) => {
3234
span.record({ ec2ConnectionType: 'ssm' })
33-
await openTerminal(node)
35+
await openTerminal(connectionManagers, node)
3436
})
3537
}),
3638

@@ -42,30 +44,30 @@ export async function activate(ctx: ExtContext): Promise<void> {
4244
}),
4345

4446
Commands.register('aws.ec2.openRemoteConnection', async (node?: Ec2Node) => {
45-
await openRemoteConnection(node)
47+
await openRemoteConnection(connectionManagers, node)
4648
}),
4749

4850
Commands.register('aws.ec2.startInstance', async (node?: Ec2Node) => {
4951
await telemetry.ec2_changeState.run(async (span) => {
5052
span.record({ ec2InstanceState: 'start' })
5153
await startInstance(node)
52-
refreshExplorer(node)
54+
await tryRefreshNode(node)
5355
})
5456
}),
5557

5658
Commands.register('aws.ec2.stopInstance', async (node?: Ec2Node) => {
5759
await telemetry.ec2_changeState.run(async (span) => {
5860
span.record({ ec2InstanceState: 'stop' })
5961
await stopInstance(node)
60-
refreshExplorer(node)
62+
await tryRefreshNode(node)
6163
})
6264
}),
6365

6466
Commands.register('aws.ec2.rebootInstance', async (node?: Ec2Node) => {
6567
await telemetry.ec2_changeState.run(async (span) => {
6668
span.record({ ec2InstanceState: 'reboot' })
6769
await rebootInstance(node)
68-
refreshExplorer(node)
70+
await tryRefreshNode(node)
6971
})
7072
}),
7173

@@ -76,3 +78,7 @@ export async function activate(ctx: ExtContext): Promise<void> {
7678
})
7779
)
7880
}
81+
82+
export async function deactivate(): Promise<void> {
83+
connectionManagers.forEach(async (manager) => await manager.dispose())
84+
}

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

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,25 @@
44
*/
55
import { Ec2InstanceNode } from './explorer/ec2InstanceNode'
66
import { Ec2Node } from './explorer/ec2ParentNode'
7-
import { Ec2ConnectionManager } from './model'
8-
import { Ec2Prompter, instanceFilter, Ec2Selection } from './prompter'
97
import { SafeEc2Instance, Ec2Client } from '../../shared/clients/ec2Client'
108
import { copyToClipboard } from '../../shared/utilities/messages'
11-
import { getLogger } from '../../shared/logger'
129
import { ec2LogSchema } from './ec2LogDocumentProvider'
1310
import { getAwsConsoleUrl } from '../../shared/awsConsole'
1411
import { showRegionPrompter } from '../../auth/utils'
1512
import { openUrl } from '../../shared/utilities/vsCodeUtils'
1613
import { showFile } from '../../shared/utilities/textDocumentUtilities'
14+
import { Ec2ConnecterMap } from './connectionManagerMap'
15+
import { Ec2Prompter, Ec2Selection, instanceFilter } from './prompter'
1716

18-
export function refreshExplorer(node?: Ec2Node) {
19-
if (node) {
20-
const n = node instanceof Ec2InstanceNode ? node.parent : node
21-
n.refreshNode().catch((e) => {
22-
getLogger().error('refreshNode failed: %s', (e as Error).message)
23-
})
24-
}
25-
}
26-
27-
export async function openTerminal(node?: Ec2Node) {
17+
export async function openTerminal(connectionManagers: Ec2ConnecterMap, node?: Ec2Node) {
2818
const selection = await getSelection(node)
29-
30-
const connectionManager = new Ec2ConnectionManager(selection.region)
19+
const connectionManager = connectionManagers.getOrInit(selection.region)
3120
await connectionManager.attemptToOpenEc2Terminal(selection)
3221
}
3322

34-
export async function openRemoteConnection(node?: Ec2Node) {
23+
export async function openRemoteConnection(connectionManagers: Ec2ConnecterMap, node?: Ec2Node) {
3524
const selection = await getSelection(node)
36-
const connectionManager = new Ec2ConnectionManager(selection.region)
25+
const connectionManager = connectionManagers.getOrInit(selection.region)
3726
await connectionManager.tryOpenRemoteConnection(selection)
3827
}
3928

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { getLogger } from '../../shared'
7+
import { Ec2Connecter } from './model'
8+
9+
export class Ec2ConnecterMap extends Map<string, Ec2Connecter> {
10+
private static warnSize: number = 25
11+
12+
public getOrInit(regionCode: string) {
13+
return this.has(regionCode) ? this.get(regionCode)! : this.initManager(regionCode)
14+
}
15+
16+
private initManager(regionCode: string): Ec2Connecter {
17+
if (this.size >= Ec2ConnecterMap.warnSize) {
18+
getLogger().warn(
19+
`Connection manager exceeded threshold of ${Ec2ConnecterMap.warnSize} with ${this.size} active connections`
20+
)
21+
}
22+
const newConnectionManager = new Ec2Connecter(regionCode)
23+
this.set(regionCode, newConnectionManager)
24+
return newConnectionManager
25+
}
26+
}

packages/core/src/awsService/ec2/explorer/ec2InstanceNode.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { SafeEc2Instance } from '../../../shared/clients/ec2Client'
1010
import globals from '../../../shared/extensionGlobals'
1111
import { getIconCode } from '../utils'
1212
import { Ec2Selection } from '../prompter'
13-
import { Ec2ParentNode } from './ec2ParentNode'
13+
import { Ec2Node, Ec2ParentNode } from './ec2ParentNode'
1414
import { EC2 } from 'aws-sdk'
15+
import { getLogger } from '../../../shared'
1516

1617
export const Ec2InstanceRunningContext = 'awsEc2RunningNode'
1718
export const Ec2InstanceStoppedContext = 'awsEc2StoppedNode'
@@ -101,3 +102,14 @@ export class Ec2InstanceNode extends AWSTreeNodeBase implements AWSResourceNode
101102
await vscode.commands.executeCommand('aws.refreshAwsExplorerNode', this)
102103
}
103104
}
105+
106+
export async function tryRefreshNode(node?: Ec2Node) {
107+
if (node) {
108+
const n = node instanceof Ec2InstanceNode ? node.parent : node
109+
try {
110+
await n.refreshNode()
111+
} catch (e) {
112+
getLogger().error('refreshNode failed: %s', (e as Error).message)
113+
}
114+
}
115+
}

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import * as vscode from 'vscode'
66
import { Session } from 'aws-sdk/clients/ssm'
7-
import { IAM, SSM } from 'aws-sdk'
7+
import { EC2, IAM, SSM } from 'aws-sdk'
88
import { Ec2Selection } from './prompter'
99
import { getOrInstallCli } from '../../shared/utilities/cliUtils'
1010
import { isCloud9 } from '../../shared/extensionUtilities'
@@ -25,20 +25,24 @@ import { createBoundProcess } from '../../codecatalyst/model'
2525
import { getLogger } from '../../shared/logger/logger'
2626
import { CancellationError, Timeout } from '../../shared/utilities/timeoutUtils'
2727
import { showMessageWithCancel } from '../../shared/utilities/messages'
28-
import { SshConfig, sshLogFileLocation } from '../../shared/sshConfig'
28+
import { SshConfig } from '../../shared/sshConfig'
2929
import { SshKeyPair } from './sshKeyPair'
30+
import { Ec2SessionTracker } from './remoteSessionManager'
31+
import { getEc2SsmEnv } from './utils'
3032

3133
export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMConnect' | 'EC2SSMAgentStatus'
3234

33-
interface Ec2RemoteEnv extends VscodeRemoteConnection {
35+
export interface Ec2RemoteEnv extends VscodeRemoteConnection {
3436
selection: Ec2Selection
3537
keyPair: SshKeyPair
38+
ssmSession: SSM.StartSessionResponse
3639
}
3740

38-
export class Ec2ConnectionManager {
41+
export class Ec2Connecter implements vscode.Disposable {
3942
protected ssmClient: SsmClient
4043
protected ec2Client: Ec2Client
4144
protected iamClient: DefaultIamClient
45+
protected sessionManager: Ec2SessionTracker
4246

4347
private policyDocumentationUri = vscode.Uri.parse(
4448
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html'
@@ -52,6 +56,7 @@ export class Ec2ConnectionManager {
5256
this.ssmClient = this.createSsmSdkClient()
5357
this.ec2Client = this.createEc2SdkClient()
5458
this.iamClient = this.createIamSdkClient()
59+
this.sessionManager = new Ec2SessionTracker(regionCode, this.ssmClient)
5560
}
5661

5762
protected createSsmSdkClient(): SsmClient {
@@ -66,6 +71,18 @@ export class Ec2ConnectionManager {
6671
return new DefaultIamClient(this.regionCode)
6772
}
6873

74+
public async addActiveSession(sessionId: SSM.SessionId, instanceId: EC2.InstanceId): Promise<void> {
75+
await this.sessionManager.addSession(instanceId, sessionId)
76+
}
77+
78+
public async dispose(): Promise<void> {
79+
await this.sessionManager.dispose()
80+
}
81+
82+
public isConnectedTo(instanceId: string): boolean {
83+
return this.sessionManager.isConnectedTo(instanceId)
84+
}
85+
6986
public async getAttachedIamRole(instanceId: string): Promise<IAM.Role | undefined> {
7087
const IamInstanceProfile = await this.ec2Client.getAttachedIamInstanceProfile(instanceId)
7188
if (IamInstanceProfile && IamInstanceProfile.Arn) {
@@ -183,6 +200,7 @@ export class Ec2ConnectionManager {
183200
this.throwGeneralConnectionError(selection, err as Error)
184201
}
185202
}
203+
186204
public async prepareEc2RemoteEnvWithProgress(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
187205
const timeout = new Timeout(60000)
188206
await showMessageWithCancel('AWS: Opening remote connection...', timeout)
@@ -204,8 +222,10 @@ export class Ec2ConnectionManager {
204222

205223
throw err
206224
}
207-
const session = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession')
208-
const vars = getEc2SsmEnv(selection, ssm, session)
225+
const ssmSession = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession')
226+
await this.addActiveSession(selection.instanceId, ssmSession.SessionId!)
227+
228+
const vars = getEc2SsmEnv(selection, ssm, ssmSession)
209229
const envProvider = async () => {
210230
return { [sshAgentSocketVariable]: await startSshAgent(), ...vars }
211231
}
@@ -223,6 +243,7 @@ export class Ec2ConnectionManager {
223243
SessionProcess,
224244
selection,
225245
keyPair,
246+
ssmSession,
226247
}
227248
}
228249

@@ -267,17 +288,3 @@ export class Ec2ConnectionManager {
267288
throw new ToolkitError(`Unrecognized OS name ${osName} on instance ${instanceId}`, { code: 'UnknownEc2OS' })
268289
}
269290
}
270-
271-
function getEc2SsmEnv(selection: Ec2Selection, ssmPath: string, session: SSM.StartSessionResponse): NodeJS.ProcessEnv {
272-
return Object.assign(
273-
{
274-
AWS_REGION: selection.region,
275-
AWS_SSM_CLI: ssmPath,
276-
LOG_FILE_LOCATION: sshLogFileLocation('ec2', selection.instanceId),
277-
STREAM_URL: session.StreamUrl,
278-
SESSION_ID: session.SessionId,
279-
TOKEN: session.TokenValue,
280-
},
281-
process.env
282-
)
283-
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { isValidResponse } from '../../shared/wizards/wizard'
1010
import { CancellationError } from '../../shared/utilities/timeoutUtils'
1111
import { AsyncCollection } from '../../shared/utilities/asyncCollection'
1212
import { getIconCode } from './utils'
13+
import { Ec2Node } from './explorer/ec2ParentNode'
14+
import { Ec2InstanceNode } from './explorer/ec2InstanceNode'
1315

1416
export type instanceFilter = (instance: SafeEc2Instance) => boolean
1517
export interface Ec2Selection {
@@ -72,3 +74,9 @@ export class Ec2Prompter {
7274
)
7375
}
7476
}
77+
78+
export async function getSelection(node?: Ec2Node, filter?: instanceFilter): Promise<Ec2Selection> {
79+
const prompter = new Ec2Prompter(filter)
80+
const selection = node && node instanceof Ec2InstanceNode ? node.toSelection() : await prompter.promptUser()
81+
return selection
82+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { EC2, SSM } from 'aws-sdk'
7+
import { SsmClient } from '../../shared/clients/ssmClient'
8+
import { Disposable } from 'vscode'
9+
10+
export class Ec2SessionTracker extends Map<EC2.InstanceId, SSM.SessionId> implements Disposable {
11+
public constructor(
12+
readonly regionCode: string,
13+
protected ssmClient: SsmClient
14+
) {
15+
super()
16+
}
17+
18+
public async addSession(instanceId: EC2.InstanceId, sessionId: SSM.SessionId): Promise<void> {
19+
if (this.isConnectedTo(instanceId)) {
20+
const existingSessionId = this.get(instanceId)!
21+
await this.ssmClient.terminateSessionFromId(existingSessionId)
22+
this.set(instanceId, sessionId)
23+
} else {
24+
this.set(instanceId, sessionId)
25+
}
26+
}
27+
28+
private async disconnectEnv(instanceId: EC2.InstanceId): Promise<void> {
29+
await this.ssmClient.terminateSessionFromId(this.get(instanceId)!)
30+
this.delete(instanceId)
31+
}
32+
33+
public async dispose(): Promise<void> {
34+
this.forEach(async (_sessionId, instanceId) => await this.disconnectEnv(instanceId))
35+
}
36+
37+
public isConnectedTo(instanceId: EC2.InstanceId): boolean {
38+
return this.has(instanceId)
39+
}
40+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
*/
55

66
import { SafeEc2Instance } from '../../shared/clients/ec2Client'
7+
import { copyToClipboard } from '../../shared/utilities/messages'
8+
import { Ec2Selection } from './prompter'
9+
import { sshLogFileLocation } from '../../shared/sshConfig'
10+
import { SSM } from 'aws-sdk'
711

812
export function getIconCode(instance: SafeEc2Instance) {
913
if (instance.LastSeenStatus === 'running') {
@@ -16,3 +20,25 @@ export function getIconCode(instance: SafeEc2Instance) {
1620

1721
return 'loading~spin'
1822
}
23+
24+
export async function copyInstanceId(instanceId: string): Promise<void> {
25+
await copyToClipboard(instanceId, 'Id')
26+
}
27+
28+
export function getEc2SsmEnv(
29+
selection: Ec2Selection,
30+
ssmPath: string,
31+
session: SSM.StartSessionResponse
32+
): NodeJS.ProcessEnv {
33+
return Object.assign(
34+
{
35+
AWS_REGION: selection.region,
36+
AWS_SSM_CLI: ssmPath,
37+
LOG_FILE_LOCATION: sshLogFileLocation('ec2', selection.instanceId),
38+
STREAM_URL: session.StreamUrl,
39+
SESSION_ID: session.SessionId,
40+
TOKEN: session.TokenValue,
41+
},
42+
process.env
43+
)
44+
}

0 commit comments

Comments
 (0)