Skip to content

Commit fc0a283

Browse files
authored
fix(ec2): openTerminal fails on Cloud9 instances #3677
Problem: Steps to reproduce: - create a cloud9 ec2 instance - try to use "open terminal" feature on the instance - command failed error :( Questions: - Why did the toolkit try to use the `AWSCloud9SSMInstanceProfile` role if it's not valid? - In order to check if an EC2 instance has the proper permissions, we use the EC2 SDK to get the role name associated with the instance. Then, we use the IAM SDK to list the policies associated with that role. Therefore, the toolkit got the `AWSCloud9SSMInstanceProfile` role from the EC2 SDK, but then the IAM threw an error saying the role does not exist. Currently unsure why the role is invalid. - or is it valid, but we need to pass the full ARN to whatever API is throwing the error? - The docs: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/IAM.html#listAttachedRolePolicies-property, only accept the `friendly name` which is the role name and not the ARN. Once we know why the role doesn't exist (despite being attached to an EC2 instance), that will likely lead us to how we can request more info on it via SDK. - or do we need to find a different role and ignore this one? - Only one IAM role can be attached to each EC2 instance. Since this is the role we get from the EC2 SDK, there is no other role we can use. - or can we tell the user something more meaningful about what the problem actually is, instead of just "NoSuchEntity ... the role cannot be found"? Solution: - Show a specific error about what happened. Rather than throwing the general lacking permission error, mention that there was a failure in retrieving the policies attached to a given instance. - Log the ARN of the role we receive from the EC2 SDK. Note: Because we check for policies before executing `startSession`, it is possible that the proper policies do exist on the instance, but we fail the pre-check. Thus, we don't connect despite having the proper polices. One way around this could be to attempt connection despite a missing role, however this presents another challenge of what error message to throw if that connection fails.
1 parent b1e511e commit fc0a283

File tree

3 files changed

+111
-23
lines changed

3 files changed

+111
-23
lines changed

src/ec2/model.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,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 } from '../shared/errors'
11+
import { ToolkitError, isAwsError } from '../shared/errors'
1212
import { SsmClient } from '../shared/clients/ssmClient'
1313
import { Ec2Client } from '../shared/clients/ec2Client'
1414

@@ -17,12 +17,17 @@ export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMC
1717
import { openRemoteTerminal } from '../shared/remoteSession'
1818
import { DefaultIamClient } from '../shared/clients/iamClient'
1919
import { ErrorInformation } from '../shared/errors'
20+
import { getLogger } from '../shared/logger'
2021

2122
export class Ec2ConnectionManager {
2223
private ssmClient: SsmClient
2324
private ec2Client: Ec2Client
2425
private iamClient: DefaultIamClient
2526

27+
private policyDocumentationUri = vscode.Uri.parse(
28+
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html'
29+
)
30+
2631
public constructor(readonly regionCode: string) {
2732
this.ssmClient = this.createSsmSdkClient()
2833
this.ec2Client = this.createEc2SdkClient()
@@ -41,14 +46,27 @@ export class Ec2ConnectionManager {
4146
return new DefaultIamClient(this.regionCode)
4247
}
4348

44-
protected async getAttachedPolicies(instanceId: string): Promise<IAM.attachedPoliciesListType> {
49+
protected async getAttachedPolicies(instanceId: string): Promise<IAM.AttachedPolicy[]> {
4550
const IamRole = await this.ec2Client.getAttachedIamRole(instanceId)
46-
if (!IamRole) {
51+
if (!IamRole?.Arn) {
4752
return []
4853
}
49-
const iamResponse = await this.iamClient.listAttachedRolePolicies(IamRole!.Arn!)
50-
51-
return iamResponse.AttachedPolicies ?? []
54+
try {
55+
const attachedPolicies = await this.iamClient.listAttachedRolePolicies(IamRole.Arn)
56+
return attachedPolicies
57+
} catch (e) {
58+
if (isAwsError(e) && e.code == 'NoSuchEntity') {
59+
const errorMessage = `Attached role does not exist in IAM: ${IamRole.Arn}.`
60+
getLogger().error(`ec2: ${errorMessage}`)
61+
throw ToolkitError.chain(e, errorMessage, {
62+
code: e.code,
63+
documentationUri: this.policyDocumentationUri,
64+
})
65+
}
66+
throw ToolkitError.chain(e as Error, `Failed to check policies for EC2 instance: ${instanceId}`, {
67+
code: 'PolicyCheck',
68+
})
69+
}
5270
}
5371

5472
public async hasProperPolicies(instanceId: string): Promise<boolean> {
@@ -63,11 +81,27 @@ export class Ec2ConnectionManager {
6381
return instanceStatus == 'running'
6482
}
6583

66-
private throwConnectionError(message: string, selection: Ec2Selection, errorInfo: ErrorInformation) {
84+
protected throwConnectionError(message: string, selection: Ec2Selection, errorInfo: ErrorInformation) {
6785
const generalErrorMessage = `Unable to connect to target instance ${selection.instanceId} on region ${selection.region}. `
6886
throw new ToolkitError(generalErrorMessage + message, errorInfo)
6987
}
7088

89+
protected async throwPolicyError(selection: Ec2Selection) {
90+
const role = await this.ec2Client.getAttachedIamRole(selection.instanceId)
91+
92+
const baseMessage = 'Ensure an IAM role with the required policies is attached to the instance.'
93+
const messageExtension =
94+
role && role.Arn
95+
? `Found attached role ${role.Arn}.`
96+
: `Failed to find role attached to ${selection.instanceId}`
97+
const fullMessage = `${baseMessage} ${messageExtension}`
98+
99+
this.throwConnectionError(fullMessage, selection, {
100+
code: 'EC2SSMPermission',
101+
documentationUri: this.policyDocumentationUri,
102+
})
103+
}
104+
71105
public async checkForStartSessionError(selection: Ec2Selection): Promise<void> {
72106
const isInstanceRunning = await this.isInstanceRunning(selection.instanceId)
73107
const hasProperPolicies = await this.hasProperPolicies(selection.instanceId)
@@ -79,14 +113,7 @@ export class Ec2ConnectionManager {
79113
}
80114

81115
if (!hasProperPolicies) {
82-
const message = 'Ensure the IAM role attached to the instance has the required policies.'
83-
const documentationUri = vscode.Uri.parse(
84-
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html'
85-
)
86-
this.throwConnectionError(message, selection, {
87-
code: 'EC2SSMPermission',
88-
documentationUri: documentationUri,
89-
})
116+
await this.throwPolicyError(selection)
90117
}
91118

92119
if (!isSsmAgentRunning) {

src/shared/clients/iamClient.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,20 @@ export class DefaultIamClient {
7777
return tokens[tokens.length - 1]
7878
}
7979

80-
public async listAttachedRolePolicies(arn: string): Promise<IAM.ListAttachedRolePoliciesResponse> {
80+
public async listAttachedRolePolicies(arn: string): Promise<IAM.AttachedPolicy[]> {
8181
const client = await this.createSdkClient()
8282
const roleName = this.getFriendlyName(arn)
83-
const response = await client.listAttachedRolePolicies({ RoleName: roleName }).promise()
8483

85-
return response
84+
const requester = async (request: IAM.ListAttachedRolePoliciesRequest) =>
85+
client.listAttachedRolePolicies(request).promise()
86+
87+
const collection = pageableToCollection(requester, { RoleName: roleName }, 'Marker', 'AttachedPolicies')
88+
.flatten()
89+
.filter(p => p !== undefined)
90+
.map(p => p!)
91+
92+
const policies = await collection.promise()
93+
94+
return policies
8695
}
8796
}

src/test/ec2/model.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
*/
55

66
import * as assert from 'assert'
7+
import * as sinon from 'sinon'
78
import { Ec2ConnectErrorCode, Ec2ConnectionManager } from '../../ec2/model'
89
import { SsmClient } from '../../shared/clients/ssmClient'
910
import { Ec2Client } from '../../shared/clients/ec2Client'
1011
import { attachedPoliciesListType } from 'aws-sdk/clients/iam'
1112
import { Ec2Selection } from '../../ec2/utils'
1213
import { ToolkitError } from '../../shared/errors'
13-
import { EC2 } from 'aws-sdk'
14+
import { EC2, IAM } from 'aws-sdk'
15+
import { DefaultIamClient } from '../../shared/clients/iamClient'
1416

1517
describe('Ec2ConnectClient', function () {
1618
class MockSsmClient extends SsmClient {
@@ -45,6 +47,16 @@ describe('Ec2ConnectClient', function () {
4547
protected override createEc2SdkClient(): Ec2Client {
4648
return new MockEc2Client()
4749
}
50+
51+
public async testGetAttachedPolicies(instanceId: string): Promise<IAM.attachedPoliciesListType> {
52+
return await this.getAttachedPolicies(instanceId)
53+
}
54+
55+
protected override async throwPolicyError(selection: Ec2Selection): Promise<void> {
56+
this.throwConnectionError('', selection, {
57+
code: 'EC2SSMPermission',
58+
})
59+
}
4860
}
4961

5062
describe('isInstanceRunning', async function () {
@@ -164,6 +176,8 @@ describe('Ec2ConnectClient', function () {
164176
PolicyName: 'AmazonSSMManagedEC2InstanceDefaultPolicy',
165177
},
166178
]
179+
case 'toolkitErrorInstance':
180+
throw new ToolkitError('', { code: 'NoSuchEntity' })
167181
default:
168182
return []
169183
}
@@ -177,16 +191,54 @@ describe('Ec2ConnectClient', function () {
177191
let result: boolean
178192

179193
result = await client.hasProperPolicies('firstInstance')
180-
assert.strictEqual(false, result)
194+
assert.strictEqual(result, false)
181195

182196
result = await client.hasProperPolicies('secondInstance')
183-
assert.strictEqual(true, result)
197+
assert.strictEqual(result, true)
184198

185199
result = await client.hasProperPolicies('thirdInstance')
186-
assert.strictEqual(false, result)
200+
assert.strictEqual(result, false)
187201

188202
result = await client.hasProperPolicies('fourthInstance')
189-
assert.strictEqual(false, result)
203+
assert.strictEqual(result, false)
204+
})
205+
206+
it('throws error when sdk throws error', async function () {
207+
try {
208+
await client.hasProperPolicies('toolkitErrorInstance')
209+
assert.ok(false)
210+
} catch {
211+
assert.ok(true)
212+
}
213+
})
214+
})
215+
216+
describe('getAttachedPolicies', async function () {
217+
let client: MockEc2ConnectClient
218+
219+
before(async function () {
220+
client = new MockEc2ConnectClient()
221+
})
222+
223+
it('returns empty when IamRole not found', async function () {
224+
sinon.stub(Ec2Client.prototype, 'getAttachedIamRole').resolves(undefined)
225+
const response = await client.testGetAttachedPolicies('test-instance')
226+
assert.deepStrictEqual(response, [])
227+
228+
sinon.restore()
229+
})
230+
231+
it('throws error if IamRole is found but invalid', async function () {
232+
sinon.stub(Ec2Client.prototype, 'getAttachedIamRole').resolves({ Arn: 'some-fake-role' })
233+
sinon.stub(DefaultIamClient.prototype, 'listAttachedRolePolicies').throws('NoSuchEntity')
234+
try {
235+
await client.testGetAttachedPolicies('test-instance')
236+
assert.ok(false)
237+
} catch {
238+
assert.ok(true)
239+
} finally {
240+
sinon.restore()
241+
}
190242
})
191243
})
192244
})

0 commit comments

Comments
 (0)