Skip to content

Commit 8e8c295

Browse files
authored
feat(aws-cdk): Detect presence of EC2 credentials (#724)
Automatically detect whether we're on an EC2 instance and only add looking up metadata credentials if that appears to be true. Add `--ec2creds`, `--no-ec2creds` command-line arguments to override the guessing if it happens to be wrong. This will fix long hangs for people that happen to be on machines where the metadata service address happens to be routable or blackholed, such as observed in #702. Fixes #130.
1 parent 2541508 commit 8e8c295

File tree

2 files changed

+126
-27
lines changed

2 files changed

+126
-27
lines changed

Diff for: packages/aws-cdk/bin/cdk.ts

+8-9
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,14 @@ const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit';
3030
const DEFAULTS = 'cdk.json';
3131
const PER_USER_DEFAULTS = '~/.cdk.json';
3232

33-
// tslint:disable:no-shadowed-variable
33+
// tslint:disable:no-shadowed-variable max-line-length
3434
async function parseCommandLineArguments() {
3535
const initTemplateLanuages = await availableInitLanguages;
3636
return yargs
3737
.usage('Usage: cdk -a <cdk-app> COMMAND')
3838
.option('app', { type: 'string', alias: 'a', desc: 'REQUIRED: Command-line for executing your CDK app (e.g. "node bin/my-app.js")' })
3939
.option('context', { type: 'array', alias: 'c', desc: 'Add contextual string parameter.', nargs: 1, requiresArg: 'KEY=VALUE' })
40-
// tslint:disable-next-line:max-line-length
4140
.option('plugin', { type: 'array', alias: 'p', desc: 'Name or path of a node package that extend the CDK features. Can be specified multiple times', nargs: 1 })
42-
// tslint:disable-next-line:max-line-length
4341
.option('rename', { type: 'string', desc: 'Rename stack name if different then the one defined in the cloud executable', requiresArg: '[ORIGINAL:]RENAMED' })
4442
.option('trace', { type: 'boolean', desc: 'Print trace for stack warnings' })
4543
.option('strict', { type: 'boolean', desc: 'Do not construct stacks with warnings' })
@@ -48,11 +46,10 @@ async function parseCommandLineArguments() {
4846
.option('verbose', { type: 'boolean', alias: 'v', desc: 'Show debug logs' })
4947
.option('profile', { type: 'string', desc: 'Use the indicated AWS profile as the default environment' })
5048
.option('proxy', { type: 'string', desc: 'Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified.' })
51-
// tslint:disable-next-line:max-line-length
49+
.option('ec2creds', { type: 'boolean', alias: 'i', default: undefined, desc: 'Force trying to fetch EC2 instance credentials. Default: guess EC2 instance status.' })
5250
.option('version-reporting', { type: 'boolean', desc: 'Disable insersion of the CDKMetadata resource in synthesized templates', default: undefined })
5351
.command([ 'list', 'ls' ], 'Lists all stacks in the app', yargs => yargs
5452
.option('long', { type: 'boolean', default: false, alias: 'l', desc: 'display environment information for each stack' }))
55-
// tslint:disable-next-line:max-line-length
5653
.command([ 'synthesize [STACKS..]', 'synth [STACKS..]' ], 'Synthesizes and prints the CloudFormation template for this stack', yargs => yargs
5754
.option('interactive', { type: 'boolean', alias: 'i', desc: 'interactively watch and show template updates' })
5855
.option('output', { type: 'string', alias: 'o', desc: 'write CloudFormation template for requested stacks to the given directory' }))
@@ -65,9 +62,7 @@ async function parseCommandLineArguments() {
6562
.command('diff [STACK]', 'Compares the specified stack with the deployed stack or a local template file', yargs => yargs
6663
.option('template', { type: 'string', desc: 'the path to the CloudFormation template to compare with' }))
6764
.command('metadata [STACK]', 'Returns all metadata associated with this stack')
68-
// tslint:disable-next-line:max-line-length
6965
.command('init [TEMPLATE]', 'Create a new, empty CDK project from a template. Invoked without TEMPLATE, the app template will be used.', yargs => yargs
70-
// tslint:disable-next-line:max-line-length
7166
.option('language', { type: 'string', alias: 'l', desc: 'the language to be used for the new project (default can be configured in ~/.cdk.json)', choices: initTemplateLanuages })
7267
.option('list', { type: 'boolean', desc: 'list the available templates' }))
7368
.commandDir('../lib/commands', { exclude: /^_.*/, visit: decorateCommand })
@@ -80,7 +75,7 @@ async function parseCommandLineArguments() {
8075
].join('\n\n'))
8176
.argv;
8277
}
83-
// tslint:enable:no-shadowed-variable
78+
// tslint:enable:no-shadowed-variable max-line-length
8479

8580
/**
8681
* Decorates commands discovered by ``yargs.commandDir`` in order to apply global
@@ -109,7 +104,11 @@ async function initCommandLine() {
109104

110105
debug('Command line arguments:', argv);
111106

112-
const aws = new SDK(argv.profile, argv.proxy);
107+
const aws = new SDK({
108+
profile: argv.profile,
109+
proxyAddress: argv.proxy,
110+
ec2creds: argv.ec2creds,
111+
});
113112

114113
const availableContextProviders: contextplugins.ProviderMap = {
115114
'availability-zones': new contextplugins.AZContextProviderPlugin(aws),

Diff for: packages/aws-cdk/lib/api/util/sdk.ts

+118-18
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
11
import { Environment} from '@aws-cdk/cx-api';
22
import AWS = require('aws-sdk');
3+
import child_process = require('child_process');
34
import fs = require('fs-extra');
45
import os = require('os');
56
import path = require('path');
7+
import util = require('util');
68
import { debug } from '../../logging';
79
import { PluginHost } from '../../plugin';
810
import { CredentialProviderSource, Mode } from '../aws-auth/credentials';
911
import { AccountAccessKeyCache } from './account-cache';
1012
import { SharedIniFile } from './sdk_ini_file';
1113

14+
export interface SDKOptions {
15+
/**
16+
* Profile name to use
17+
*
18+
* @default No profile
19+
*/
20+
profile?: string;
21+
22+
/**
23+
* Proxy address to use
24+
*
25+
* @default No proxy
26+
*/
27+
proxyAddress?: string;
28+
29+
/**
30+
* Whether we should try instance credentials
31+
*
32+
* True/false to force/disable. Default is to guess.
33+
*
34+
* @default Automatically determine.
35+
*/
36+
ec2creds?: boolean;
37+
}
38+
1239
/**
1340
* Source for SDK client objects
1441
*
@@ -22,22 +49,25 @@ export class SDK {
2249
private readonly defaultAwsAccount: DefaultAWSAccount;
2350
private readonly credentialsCache: CredentialsCache;
2451
private readonly defaultClientArgs: any = {};
52+
private readonly profile?: string;
2553

26-
constructor(private readonly profile: string | undefined, proxyAddress: string | undefined) {
27-
const defaultCredentialProvider = makeCLICompatibleCredentialProvider(profile);
54+
constructor(options: SDKOptions) {
55+
this.profile = options.profile;
56+
57+
const defaultCredentialProvider = makeCLICompatibleCredentialProvider(options.profile, options.ec2creds);
2858

2959
// Find the package.json from the main toolkit
3060
const pkg = (require.main as any).require('../package.json');
3161
this.defaultClientArgs.userAgent = `${pkg.name}/${pkg.version}`;
3262

3363
// https://aws.amazon.com/blogs/developer/using-the-aws-sdk-for-javascript-from-behind-a-proxy/
34-
if (proxyAddress === undefined) {
35-
proxyAddress = httpsProxyFromEnvironment();
64+
if (options.proxyAddress === undefined) {
65+
options.proxyAddress = httpsProxyFromEnvironment();
3666
}
37-
if (proxyAddress) { // Ignore empty string on purpose
38-
debug('Using proxy server: %s', proxyAddress);
67+
if (options.proxyAddress) { // Ignore empty string on purpose
68+
debug('Using proxy server: %s', options.proxyAddress);
3969
this.defaultClientArgs.httpOptions = {
40-
agent: require('proxy-agent')(proxyAddress)
70+
agent: require('proxy-agent')(options.proxyAddress)
4171
};
4272
}
4373

@@ -224,25 +254,36 @@ class DefaultAWSAccount {
224254
* file location is not given (SDK expects explicit environment variable with name).
225255
* - AWS_DEFAULT_PROFILE is also inspected for profile name (not just AWS_PROFILE).
226256
*/
227-
async function makeCLICompatibleCredentialProvider(profile: string | undefined) {
257+
async function makeCLICompatibleCredentialProvider(profile: string | undefined, ec2creds: boolean | undefined) {
228258
profile = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
229259

230260
// Need to construct filename ourselves, without appropriate environment variables
231261
// no defaults used by JS SDK.
232262
const filename = process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), '.aws', 'credentials');
233263

234-
return new AWS.CredentialProviderChain([
264+
const sources = [
235265
() => new AWS.EnvironmentCredentials('AWS'),
236266
() => new AWS.EnvironmentCredentials('AMAZON'),
237-
...(await fs.pathExists(filename) ? [() => new AWS.SharedIniFileCredentials({ profile, filename })] : []),
238-
() => {
239-
// Calling private API
240-
if ((AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials()) {
241-
return new AWS.ECSCredentials();
242-
}
243-
return new AWS.EC2MetadataCredentials();
267+
];
268+
if (fs.pathExists(filename)) {
269+
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename }));
270+
}
271+
272+
if (hasEcsCredentials()) {
273+
sources.push(() => new AWS.ECSCredentials());
274+
} else {
275+
// else if: don't get EC2 creds if we should have gotten ECS creds--ECS instances also
276+
// run on EC2 boxes but the creds represent something different. Same behavior as
277+
// upstream code.
278+
279+
if (ec2creds === undefined) { ec2creds = await hasEc2Credentials(); }
280+
281+
if (ec2creds) {
282+
sources.push(() => new AWS.EC2MetadataCredentials());
244283
}
245-
]);
284+
}
285+
286+
return new AWS.CredentialProviderChain(sources);
246287
}
247288

248289
/**
@@ -290,4 +331,63 @@ function httpsProxyFromEnvironment(): string | undefined {
290331
return process.env.HTTPS_PROXY;
291332
}
292333
return undefined;
293-
}
334+
}
335+
336+
/**
337+
* Return whether it looks like we'll have ECS credentials available
338+
*/
339+
function hasEcsCredentials() {
340+
return (AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials();
341+
}
342+
343+
/**
344+
* Return whether we're on an EC2 instance
345+
*/
346+
async function hasEc2Credentials() {
347+
debug("Determining whether we're on an EC2 instance.");
348+
349+
let instance = false;
350+
if (process.platform === 'win32') {
351+
// https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/identify_ec2_instances.html
352+
const result = await util.promisify(child_process.exec)('wmic path win32_computersystemproduct get uuid', { encoding: 'utf-8' });
353+
// output looks like
354+
// UUID
355+
// EC2AE145-D1DC-13B2-94ED-01234ABCDEF
356+
const lines = result.stdout.toString().split('\n');
357+
instance = lines.some(x => matchesRegex(/^ec2/i, x));
358+
} else {
359+
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/identify_ec2_instances.html
360+
const files: Array<[string, RegExp]> = [
361+
// This recognizes the Xen hypervisor based instances (pre-5th gen)
362+
['/sys/hypervisor/uuid', /^ec2/i],
363+
364+
// This recognizes the new Hypervisor (5th-gen instances and higher)
365+
// Can't use the advertised file '/sys/devices/virtual/dmi/id/product_uuid' because it requires root to read.
366+
// Instead, sys_vendor contains something like 'Amazon EC2'.
367+
['/sys/devices/virtual/dmi/id/sys_vendor', /ec2/i],
368+
];
369+
for (const [file, re] of files) {
370+
if (matchesRegex(re, await readIfPossible(file))) {
371+
instance = true;
372+
break;
373+
}
374+
}
375+
}
376+
377+
debug(instance ? 'Looks like EC2 instance.' : 'Does not look like EC2 instance.');
378+
return instance;
379+
}
380+
381+
async function readIfPossible(filename: string): Promise<string | undefined> {
382+
try {
383+
if (!await fs.pathExists(filename)) { return undefined; }
384+
return fs.readFile(filename, { encoding: 'utf-8' });
385+
} catch (e) {
386+
debug(e);
387+
return undefined;
388+
}
389+
}
390+
391+
function matchesRegex(re: RegExp, s: string | undefined) {
392+
return s !== undefined && re.exec(s) !== null;
393+
}

0 commit comments

Comments
 (0)