Skip to content

Commit bb1ce25

Browse files
ec2: add case-specific errors for when instance is not running and when attached IAM invalid (#3587)
* 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 * improve style + start of work on debugging error * make distinction between status error and permission error * start to add tests for error handling * 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 * fix formatting * add test for error handling * fix formatting * add extra wrapper * generalize some code to a remoteSession file * add space between pieces * retrieve IAM role attached to instance when fails to connect * remove dead import * refactor to avoid circular dependency * checks if relevant policies are on attached role * use both probes to determine source of error * update tests * change callback to async/await * remove onError parameter and utilize .catch instead * add test for permissions detection * fix test * 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 * fix outdated imports in the test * bubble error up * update test case * Remove please in text Co-authored-by: Justin M. Keyes <[email protected]> * Remove please in text in other error Co-authored-by: Justin M. Keyes <[email protected]> * remove unnecessary types and update tests to match * change name of test utility to match other examples * remove `Error` from error code * change default error message to be more concise. Co-authored-by: Justin M. Keyes <[email protected]> * change text in error message Co-authored-by: Justin M. Keyes <[email protected]> * change text on documentation button. Co-authored-by: Justin M. Keyes <[email protected]> * refactor instanceStatus functionality to be more direct * add error to prevent friendlyName extracter from returning undefined * add tests for getFriendlyName * move variable to the end of message. Co-authored-by: Justin M. Keyes <[email protected]> --------- Co-authored-by: Justin M. Keyes <[email protected]>
1 parent 909056f commit bb1ce25

File tree

13 files changed

+352
-26
lines changed

13 files changed

+352
-26
lines changed

src/auth/ui/vue/types.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ export type ServiceItemId = 'resourceExplorer' | 'codewhisperer' | 'codecatalyst
88
export function isServiceItemId(value: unknown): value is ServiceItemId {
99
return (
1010
typeof value === 'string' &&
11-
(value === 'resourceExplorer' ||
12-
value === 'codewhisperer' ||
13-
value === 'codecatalyst')
11+
(value === 'resourceExplorer' || value === 'codewhisperer' || value === 'codecatalyst')
1412
)
1513
}

src/cloudWatchLogs/commands/searchLogGroup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export function createRegionSubmenu() {
242242
getLogGroupsFromRegion,
243243
{ title: localize('AWS.cwl.searchLogGroup.logGroupPromptTitle', 'Select Log Group') },
244244
{ title: localize('AWS.cwl.searchLogGroup.regionPromptTitle', 'Select Region for Log Group') },
245-
"Log Groups"
245+
'Log Groups'
246246
)
247247
}
248248

src/ec2/activation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ 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-
tryConnect()
12+
await tryConnect()
1313
})
1414
)
1515
}

src/ec2/model.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,90 @@
44
*/
55
import * as vscode from 'vscode'
66
import { Session } from 'aws-sdk/clients/ssm'
7+
import { AWSError, IAM } from 'aws-sdk'
78
import { Ec2Selection } from './utils'
89
import { getOrInstallCli } from '../shared/utilities/cliUtils'
910
import { isCloud9 } from '../shared/extensionUtilities'
1011
import { ToolkitError, isAwsError } from '../shared/errors'
1112
import { SsmClient } from '../shared/clients/ssmClient'
13+
import { Ec2Client } from '../shared/clients/ec2Client'
14+
15+
export type Ec2ConnectErrorCode = 'EC2SSMStatus' | 'EC2SSMPermission' | 'EC2SSMConnect'
16+
1217
import { openRemoteTerminal } from '../shared/remoteSession'
18+
import { DefaultIamClient } from '../shared/clients/iamClient'
1319

1420
export class Ec2ConnectionManager {
15-
// Will need the ec2Client for probing errors,
1621
private ssmClient: SsmClient
17-
//private ec2Client: DefaultEc2Client
22+
private ec2Client: Ec2Client
23+
private iamClient: DefaultIamClient
1824

1925
public constructor(readonly regionCode: string) {
20-
this.ssmClient = new SsmClient(this.regionCode)
21-
//this.ec2Client = new DefaultEc2Client(this.regionCode)
26+
this.ssmClient = this.createSsmSdkClient()
27+
this.ec2Client = this.createEc2SdkClient()
28+
this.iamClient = this.createIamSdkClient()
29+
}
30+
31+
protected createSsmSdkClient(): SsmClient {
32+
return new SsmClient(this.regionCode)
2233
}
2334

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)
35+
protected createEc2SdkClient(): Ec2Client {
36+
return new Ec2Client(this.regionCode)
37+
}
2838

29-
throw new ToolkitError('Start Session Failed. ')
39+
protected createIamSdkClient(): DefaultIamClient {
40+
return new DefaultIamClient(this.regionCode)
41+
}
42+
43+
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) {
49+
return []
50+
}
51+
}
52+
53+
public async hasProperPolicies(instanceId: string): Promise<boolean> {
54+
const attachedPolicies = (await this.getAttachedPolicies(instanceId)).map(policy => policy.PolicyName!)
55+
const requiredPolicies = ['AmazonSSMManagedInstanceCore', 'AmazonSSMManagedEC2InstanceDefaultPolicy']
56+
57+
return requiredPolicies.every(policy => attachedPolicies.includes(policy))
58+
}
59+
60+
public async handleStartSessionError(err: AWSError, selection: Ec2Selection): Promise<Error> {
61+
const isInstanceRunning = (await this.ec2Client.getInstanceStatus(selection.instanceId)) == 'running'
62+
const generalErrorMessage = `Unable to connect to target instance ${selection.instanceId} on region ${selection.region}. `
63+
const hasProperPolicies = await this.hasProperPolicies(selection.instanceId)
64+
65+
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+
)
71+
}
72+
73+
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+
}
82+
)
83+
}
84+
85+
throw new ToolkitError('Is SSM running on the target instance?', {
86+
code: 'EC2SSMConnect',
87+
documentationUri: vscode.Uri.parse(
88+
'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started.html'
89+
),
90+
})
3091
}
3192

3293
private async openSessionInTerminal(session: Session, selection: Ec2Selection) {
@@ -49,7 +110,7 @@ export class Ec2ConnectionManager {
49110
await this.openSessionInTerminal(response, selection)
50111
} catch (err) {
51112
if (isAwsError(err)) {
52-
await this.handleStartSessionError(err)
113+
await this.handleStartSessionError(err, selection)
53114
} else {
54115
throw err
55116
}

src/ec2/prompter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function createEc2ConnectPrompter(): RegionSubmenu<string> {
2525
return new RegionSubmenu(
2626
async region => (await getInstanceIdsFromRegion(region)).map(asQuickpickItem).promise(),
2727
{ title: 'Select EC2 Instance Id' },
28-
{ title: 'Select Region for EC2 Instance' },
29-
"Instances"
28+
{ title: 'Select Region for EC2 Instance' },
29+
'Instances'
3030
)
3131
}

src/ec2/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ export interface Ec2Selection {
1313

1414
export async function getInstanceIdsFromRegion(regionCode: string): Promise<AsyncCollection<string>> {
1515
const client = new Ec2Client(regionCode)
16-
return client.getInstanceIds()
16+
return await client.getInstanceIds()
1717
}

src/extension.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ import { join } from 'path'
6363
import { Experiments, Settings } from './shared/settings'
6464
import { isReleaseVersion } from './shared/vscode/env'
6565
import { Commands, registerErrorHandler } from './shared/vscode/commands2'
66-
import { isUserCancelledError, resolveErrorMessageToDisplay } from './shared/errors'
66+
import { ToolkitError, isUserCancelledError, resolveErrorMessageToDisplay } from './shared/errors'
6767
import { Logging } from './shared/logger/commands'
6868
import { UriHandler } from './shared/vscode/uriHandler'
6969
import { telemetry } from './shared/telemetry/telemetry'
7070
import { Auth } from './auth/auth'
7171
import { openUrl } from './shared/utilities/vsCodeUtils'
72+
import { showMessageWithUrl } from './shared/utilities/messages'
7273

7374
let localize: nls.LocalizeFunc
7475

@@ -280,16 +281,19 @@ async function handleError(error: unknown, topic: string, defaultMessage: string
280281
getLogger().verbose(`${topic}: user cancelled`)
281282
return
282283
}
283-
284284
const logsItem = localize('AWS.generic.message.viewLogs', 'View Logs...')
285285
const logId = getLogger().error(`${topic}: %s`, error)
286286
const message = resolveErrorMessageToDisplay(error, defaultMessage)
287287

288-
await vscode.window.showErrorMessage(message, logsItem).then(async resp => {
289-
if (resp === logsItem) {
290-
await Logging.declared.viewLogsAtMessage.execute(logId)
291-
}
292-
})
288+
if (error instanceof ToolkitError && error.documentationUri) {
289+
await showMessageWithUrl(message, error.documentationUri, 'View Documentation', 'error')
290+
} else {
291+
await vscode.window.showErrorMessage(message, logsItem).then(async resp => {
292+
if (resp === logsItem) {
293+
await Logging.declared.viewLogsAtMessage.execute(logId)
294+
}
295+
})
296+
}
293297
}
294298

295299
export async function deactivate() {

src/shared/clients/ec2Client.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EC2 } from 'aws-sdk'
77
import globals from '../extensionGlobals'
88
import { AsyncCollection } from '../utilities/asyncCollection'
99
import { pageableToCollection } from '../utilities/collectionUtils'
10+
import { IamInstanceProfile } from 'aws-sdk/clients/ec2'
1011

1112
export class Ec2Client {
1213
public constructor(public readonly regionCode: string) {}
@@ -34,4 +35,50 @@ export class Ec2Client {
3435
.map(instance => instance?.InstanceId)
3536
.filter(instanceId => instanceId !== undefined)
3637
}
38+
39+
public async getInstanceStatus(instanceId: string): Promise<EC2.InstanceStateName> {
40+
const client = await this.createSdkClient()
41+
const requester = async (request: EC2.DescribeInstanceStatusRequest) =>
42+
client.describeInstanceStatus(request).promise()
43+
44+
const response = await pageableToCollection(
45+
requester,
46+
{ InstanceIds: [instanceId], IncludeAllInstances: true },
47+
'NextToken',
48+
'InstanceStatuses'
49+
)
50+
.flatten()
51+
.map(instanceStatus => instanceStatus!.InstanceState!.Name!)
52+
.promise()
53+
54+
return response[0]
55+
}
56+
57+
/**
58+
* Retrieve IAM role attached to given EC2 instance.
59+
* @param instanceId target EC2 instance ID
60+
* @returns IAM role associated with instance, or undefined if none exists.
61+
*/
62+
public async getAttachedIamRole(instanceId: string): Promise<IamInstanceProfile | undefined> {
63+
const client = await this.createSdkClient()
64+
const instanceFilter: EC2.Filter[] = [
65+
{
66+
Name: 'instance-id',
67+
Values: [instanceId],
68+
},
69+
]
70+
const requester = async (request: EC2.DescribeIamInstanceProfileAssociationsRequest) =>
71+
client.describeIamInstanceProfileAssociations(request).promise()
72+
const response = await pageableToCollection(
73+
requester,
74+
{ Filters: instanceFilter },
75+
'NextToken',
76+
'IamInstanceProfileAssociations'
77+
)
78+
.flatten()
79+
.map(val => val?.IamInstanceProfile)
80+
.promise()
81+
82+
return response && response.length ? response[0] : undefined
83+
}
3784
}

src/shared/clients/iamClient.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,22 @@ export class DefaultIamClient {
6868
private async createSdkClient(): Promise<IAM> {
6969
return await globals.sdkClientBuilder.createAwsService(IAM, undefined, this.regionCode)
7070
}
71+
72+
public getFriendlyName(arn: string): string {
73+
const tokens = arn.split('/')
74+
if (tokens.length < 2) {
75+
throw new Error(
76+
`Invalid IAM role ARN (expected format: arn:aws:iam::{id}/{name}): ${arn}`
77+
)
78+
}
79+
return tokens[tokens.length - 1]
80+
}
81+
82+
public async listAttachedRolePolicies(arn: string): Promise<IAM.ListAttachedRolePoliciesResponse> {
83+
const client = await this.createSdkClient()
84+
const roleName = this.getFriendlyName(arn)
85+
const response = await client.listAttachedRolePolicies({ RoleName: roleName }).promise()
86+
87+
return response
88+
}
7189
}

src/shared/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ export class ToolkitError extends Error implements ErrorInformation {
155155
return this.info.cancelled ?? isUserCancelledError(this.cause)
156156
}
157157

158+
/**
159+
* The associated documentation, if it exists. Otherwise undefined.
160+
*/
161+
public get documentationUri(): vscode.Uri | undefined {
162+
return this.info.documentationUri
163+
}
164+
158165
/**
159166
* A formatted string that is analogous to a stack trace. While stack traces enumerate every
160167
* call site, this trace enumerates every throw site.

0 commit comments

Comments
 (0)