Skip to content

Commit 0fe8594

Browse files
authored
fix(ec2): check if instance connectable before connecting. (#3613)
Problem: If the user changes the IAM role associated with the instance, the connect command fails whether it be adding or removing the necessary policies. The root of this problem is ultimately a bug in the usage of the startSession SDK. That is, there are ways this command can fail such that it doesn't throw an error, but the terminal will crash on entry. Solution: We check for ways the startSession command can fail before running it. If any are detected, we throw a relevant error. This way, we only call this command when we are confident it will succeed.
1 parent 45a3efc commit 0fe8594

File tree

4 files changed

+127
-61
lines changed

4 files changed

+127
-61
lines changed

src/ec2/model.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
*/
55
import * as vscode from 'vscode'
66
import { Session } from 'aws-sdk/clients/ssm'
7-
import { AWSError, IAM } from 'aws-sdk'
7+
import { IAM } from 'aws-sdk'
88
import { Ec2Selection } from './utils'
99
import { getOrInstallCli } from '../shared/utilities/cliUtils'
1010
import { isCloud9 } from '../shared/extensionUtilities'
11-
import { ToolkitError, isAwsError } from '../shared/errors'
11+
import { ToolkitError } from '../shared/errors'
1212
import { SsmClient } from '../shared/clients/ssmClient'
1313
import { Ec2Client } from '../shared/clients/ec2Client'
1414

1515
export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMConnect'
1616

1717
import { openRemoteTerminal } from '../shared/remoteSession'
1818
import { DefaultIamClient } from '../shared/clients/iamClient'
19+
import { ErrorInformation } from '../shared/errors'
1920

2021
export class Ec2ConnectionManager {
2122
private ssmClient: SsmClient
@@ -41,56 +42,51 @@ export class Ec2ConnectionManager {
4142
}
4243

4344
protected async getAttachedPolicies(instanceId: string): Promise<IAM.attachedPoliciesListType> {
44-
try {
45-
const IamRole = await this.ec2Client.getAttachedIamRole(instanceId)
46-
const iamResponse = await this.iamClient.listAttachedRolePolicies(IamRole!.Arn!)
47-
return iamResponse.AttachedPolicies!
48-
} catch (err) {
45+
const IamRole = await this.ec2Client.getAttachedIamRole(instanceId)
46+
if (!IamRole) {
4947
return []
5048
}
49+
const iamResponse = await this.iamClient.listAttachedRolePolicies(IamRole!.Arn!)
50+
51+
return iamResponse.AttachedPolicies ?? []
5152
}
5253

5354
public async hasProperPolicies(instanceId: string): Promise<boolean> {
5455
const attachedPolicies = (await this.getAttachedPolicies(instanceId)).map(policy => policy.PolicyName!)
5556
const requiredPolicies = ['AmazonSSMManagedInstanceCore', 'AmazonSSMManagedEC2InstanceDefaultPolicy']
5657

57-
return requiredPolicies.every(policy => attachedPolicies.includes(policy))
58+
return requiredPolicies.length !== 0 && requiredPolicies.every(policy => attachedPolicies.includes(policy))
5859
}
5960

60-
public async handleStartSessionError(err: AWSError, selection: Ec2Selection): Promise<Error> {
61-
const isInstanceRunning = (await this.ec2Client.getInstanceStatus(selection.instanceId)) == 'running'
61+
public async isInstanceRunning(instanceId: string): Promise<boolean> {
62+
const instanceStatus = await this.ec2Client.getInstanceStatus(instanceId)
63+
return instanceStatus == 'running'
64+
}
65+
66+
private throwConnectionError(message: string, selection: Ec2Selection, errorInfo: ErrorInformation) {
6267
const generalErrorMessage = `Unable to connect to target instance ${selection.instanceId} on region ${selection.region}. `
68+
throw new ToolkitError(generalErrorMessage + message, errorInfo)
69+
}
70+
71+
public async checkForStartSessionError(selection: Ec2Selection): Promise<void> {
72+
const isInstanceRunning = await this.isInstanceRunning(selection.instanceId)
6373
const hasProperPolicies = await this.hasProperPolicies(selection.instanceId)
6474

6575
if (!isInstanceRunning) {
66-
throw new ToolkitError(
67-
generalErrorMessage +
68-
'Ensure the target instance is running and not currently starting, stopping, or stopped.',
69-
{ code: 'EC2SSMStatus' }
70-
)
76+
const message = 'Ensure the target instance is running and not currently starting, stopping, or stopped.'
77+
this.throwConnectionError(message, selection, { code: 'EC2SSMStatus' })
7178
}
7279

7380
if (!hasProperPolicies) {
74-
throw new ToolkitError(
75-
generalErrorMessage + 'Ensure the IAM role attached to the instance has the required policies.',
76-
{
77-
code: 'EC2SSMPermission',
78-
documentationUri: vscode.Uri.parse(
79-
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html'
80-
),
81-
}
81+
const message = 'Ensure the IAM role attached to the instance has the required policies.'
82+
const documentationUri = vscode.Uri.parse(
83+
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html'
8284
)
85+
this.throwConnectionError(message, selection, {
86+
code: 'EC2SSMPermission',
87+
documentationUri: documentationUri,
88+
})
8389
}
84-
85-
throw new ToolkitError(
86-
'Ensure SSM is running on target instance. For more information see the documentation.',
87-
{
88-
code: 'EC2SSMConnect',
89-
documentationUri: vscode.Uri.parse(
90-
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started.html'
91-
),
92-
}
93-
)
9490
}
9591

9692
private async openSessionInTerminal(session: Session, selection: Ec2Selection) {
@@ -108,15 +104,16 @@ export class Ec2ConnectionManager {
108104
}
109105

110106
public async attemptEc2Connection(selection: Ec2Selection): Promise<void> {
107+
await this.checkForStartSessionError(selection)
111108
try {
112109
const response = await this.ssmClient.startSession(selection.instanceId)
113110
await this.openSessionInTerminal(response, selection)
114-
} catch (err) {
115-
if (isAwsError(err)) {
116-
await this.handleStartSessionError(err, selection)
117-
} else {
118-
throw err
119-
}
111+
} catch (err: unknown) {
112+
// Default error if pre-check fails.
113+
this.throwConnectionError('Check that the SSM Agent is running on target instance', selection, {
114+
code: 'EC2SSMConnect',
115+
cause: err as Error,
116+
})
120117
}
121118
}
122119
}

src/shared/clients/ec2Client.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,31 @@ export class Ec2Client {
1919
private async createSdkClient(): Promise<EC2> {
2020
return await globals.sdkClientBuilder.createAwsService(EC2, undefined, this.regionCode)
2121
}
22-
public async getInstances(): Promise<AsyncCollection<Ec2Instance>> {
22+
23+
public async getInstances(filters?: EC2.Filter[]): Promise<AsyncCollection<EC2.Instance>> {
2324
const client = await this.createSdkClient()
25+
2426
const requester = async (request: EC2.DescribeInstancesRequest) => client.describeInstances(request).promise()
25-
const collection = pageableToCollection(requester, {}, 'NextToken', 'Reservations')
27+
const collection = pageableToCollection(
28+
requester,
29+
filters ? { Filters: filters } : {},
30+
'NextToken',
31+
'Reservations'
32+
)
2633
const instances = this.getInstancesFromReservations(collection)
2734
return instances
2835
}
2936

30-
private lookupTagKey(tags: EC2.Tag[], targetKey: string): string | undefined {
31-
return tags.filter(tag => tag.Key == targetKey)[0].Value
32-
}
33-
3437
public getInstancesFromReservations(
3538
reservations: AsyncCollection<EC2.ReservationList | undefined>
36-
): AsyncCollection<Ec2Instance> {
39+
): AsyncCollection<EC2.Instance> {
3740
return reservations
3841
.flatten()
3942
.map(instanceList => instanceList?.Instances)
4043
.flatten()
4144
.filter(instance => instance!.InstanceId !== undefined)
4245
.map(instance => {
43-
return instance!.Tags ? { ...instance, name: this.lookupTagKey(instance!.Tags!, 'Name') } : instance!
46+
return instance!.Tags ? { ...instance, name: lookupTagKey(instance!.Tags!, 'Name') } : instance!
4447
})
4548
}
4649

@@ -62,19 +65,23 @@ export class Ec2Client {
6265
return response[0]
6366
}
6467

65-
/**
66-
* Retrieve IAM role attached to given EC2 instance.
67-
* @param instanceId target EC2 instance ID
68-
* @returns IAM role associated with instance, or undefined if none exists.
69-
*/
70-
public async getAttachedIamRole(instanceId: string): Promise<IamInstanceProfile | undefined> {
71-
const client = await this.createSdkClient()
72-
const instanceFilter: EC2.Filter[] = [
68+
public getInstancesFilter(instanceIds: string[]): EC2.Filter[] {
69+
return [
7370
{
7471
Name: 'instance-id',
75-
Values: [instanceId],
72+
Values: instanceIds,
7673
},
7774
]
75+
}
76+
77+
/**
78+
* Retrieve IAM Association for a given EC2 instance.
79+
* @param instanceId target EC2 instance ID
80+
* @returns IAM Association for instance
81+
*/
82+
private async getIamInstanceProfileAssociation(instanceId: string): Promise<EC2.IamInstanceProfileAssociation> {
83+
const client = await this.createSdkClient()
84+
const instanceFilter = this.getInstancesFilter([instanceId])
7885
const requester = async (request: EC2.DescribeIamInstanceProfileAssociationsRequest) =>
7986
client.describeIamInstanceProfileAssociations(request).promise()
8087
const response = await pageableToCollection(
@@ -84,9 +91,27 @@ export class Ec2Client {
8491
'IamInstanceProfileAssociations'
8592
)
8693
.flatten()
87-
.map(val => val?.IamInstanceProfile)
94+
.filter(association => association !== undefined)
8895
.promise()
8996

90-
return response && response.length ? response[0] : undefined
97+
return response[0]!
9198
}
99+
100+
/**
101+
* Retrieve IAM role attached to given EC2 instance.
102+
* @param instanceId target EC2 instance ID
103+
* @returns IAM role associated with instance or undefined if none exists.
104+
*/
105+
public async getAttachedIamRole(instanceId: string): Promise<IamInstanceProfile | undefined> {
106+
const association = await this.getIamInstanceProfileAssociation(instanceId)
107+
return association ? association.IamInstanceProfile : undefined
108+
}
109+
}
110+
111+
export function getNameOfInstance(instance: EC2.Instance): string | undefined {
112+
return instance.Tags ? lookupTagKey(instance.Tags, 'Name') : undefined
113+
}
114+
115+
function lookupTagKey(tags: EC2.Tag[], targetKey: string) {
116+
return tags.filter(tag => tag.Key == targetKey)[0].Value
92117
}

src/test/ec2/model.test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Ec2Client } from '../../shared/clients/ec2Client'
1010
import { attachedPoliciesListType } from 'aws-sdk/clients/iam'
1111
import { Ec2Selection } from '../../ec2/utils'
1212
import { ToolkitError } from '../../shared/errors'
13-
import { AWSError, EC2 } from 'aws-sdk'
13+
import { EC2 } from 'aws-sdk'
1414

1515
describe('Ec2ConnectClient', function () {
1616
class MockSsmClient extends SsmClient {
@@ -42,9 +42,25 @@ describe('Ec2ConnectClient', function () {
4242
return new MockEc2Client()
4343
}
4444
}
45+
46+
describe('isInstanceRunning', async function () {
47+
let client: MockEc2ConnectClient
48+
49+
before(function () {
50+
client = new MockEc2ConnectClient()
51+
})
52+
53+
it('only returns true with the instance is running', async function () {
54+
const actualFirstResult = await client.isInstanceRunning('running:noPolicies')
55+
const actualSecondResult = await client.isInstanceRunning('stopped:noPolicies')
56+
57+
assert.strictEqual(true, actualFirstResult)
58+
assert.strictEqual(false, actualSecondResult)
59+
})
60+
})
61+
4562
describe('handleStartSessionError', async function () {
4663
let client: MockEc2ConnectClientForError
47-
const dummyError: AWSError = { name: 'testName', message: 'testMessage', code: 'testCode', time: new Date() }
4864

4965
class MockEc2ConnectClientForError extends MockEc2ConnectClient {
5066
public override async hasProperPolicies(instanceId: string): Promise<boolean> {
@@ -58,7 +74,7 @@ describe('Ec2ConnectClient', function () {
5874
it('determines which error to throw based on if instance is running', async function () {
5975
async function assertThrowsErrorCode(testInstance: Ec2Selection, errCode: Ec2ConnectErrorCode) {
6076
try {
61-
await client.handleStartSessionError(dummyError, testInstance)
77+
await client.checkForStartSessionError(testInstance)
6278
} catch (err: unknown) {
6379
assert.strictEqual((err as ToolkitError).code, errCode)
6480
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,31 @@ describe('extractInstancesFromReservations', function () {
9494
)
9595
})
9696
})
97+
98+
describe('getSingleInstanceFilter', function () {
99+
const client = new Ec2Client('')
100+
101+
it('returns proper filter when given instanceId', function () {
102+
const testInstanceId1 = 'test'
103+
const actualFilters1 = client.getInstancesFilter([testInstanceId1])
104+
const expectedFilters1: EC2.Filter[] = [
105+
{
106+
Name: 'instance-id',
107+
Values: [testInstanceId1],
108+
},
109+
]
110+
111+
assert.deepStrictEqual(expectedFilters1, actualFilters1)
112+
113+
const testInstanceId2 = 'test2'
114+
const actualFilters2 = client.getInstancesFilter([testInstanceId1, testInstanceId2])
115+
const expectedFilters2: EC2.Filter[] = [
116+
{
117+
Name: 'instance-id',
118+
Values: [testInstanceId1, testInstanceId2],
119+
},
120+
]
121+
122+
assert.deepStrictEqual(expectedFilters2, actualFilters2)
123+
})
124+
})

0 commit comments

Comments
 (0)