Skip to content

Commit

Permalink
feat(aws-cdk): Detect presence of EC2 credentials
Browse files Browse the repository at this point in the history
Automatically detect whether we're on an EC2 instance and only add
looking up metadata credentials if that appears to be true. Add
`--instance`, `--no-instance` 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.
  • Loading branch information
Rico Huijbers committed Sep 17, 2018
1 parent 815915e commit 2acb0a9
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 27 deletions.
17 changes: 8 additions & 9 deletions packages/aws-cdk/bin/cdk.ts
Expand Up @@ -30,16 +30,14 @@ const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit';
const DEFAULTS = 'cdk.json';
const PER_USER_DEFAULTS = '~/.cdk.json';

// tslint:disable:no-shadowed-variable
// tslint:disable:no-shadowed-variable max-line-length
async function parseCommandLineArguments() {
const initTemplateLanuages = await availableInitLanguages;
return yargs
.usage('Usage: cdk -a <cdk-app> COMMAND')
.option('app', { type: 'string', alias: 'a', desc: 'REQUIRED: Command-line for executing your CDK app (e.g. "node bin/my-app.js")' })
.option('context', { type: 'array', alias: 'c', desc: 'Add contextual string parameter.', nargs: 1, requiresArg: 'KEY=VALUE' })
// tslint:disable-next-line:max-line-length
.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 })
// tslint:disable-next-line:max-line-length
.option('rename', { type: 'string', desc: 'Rename stack name if different then the one defined in the cloud executable', requiresArg: '[ORIGINAL:]RENAMED' })
.option('trace', { type: 'boolean', desc: 'Print trace for stack warnings' })
.option('strict', { type: 'boolean', desc: 'Do not construct stacks with warnings' })
Expand All @@ -48,11 +46,10 @@ async function parseCommandLineArguments() {
.option('verbose', { type: 'boolean', alias: 'v', desc: 'Show debug logs' })
.option('profile', { type: 'string', desc: 'Use the indicated AWS profile as the default environment' })
.option('proxy', { type: 'string', desc: 'Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified.' })
// tslint:disable-next-line:max-line-length
.option('instance', { type: 'boolean', alias: 'i', default: undefined, desc: 'Force trying to fetch EC2 instance credentials. Default: guess EC2 instance status.' })
.option('version-reporting', { type: 'boolean', desc: 'Disable insersion of the CDKMetadata resource in synthesized templates', default: undefined })
.command([ 'list', 'ls' ], 'Lists all stacks in the app', yargs => yargs
.option('long', { type: 'boolean', default: false, alias: 'l', desc: 'display environment information for each stack' }))
// tslint:disable-next-line:max-line-length
.command([ 'synthesize [STACKS..]', 'synth [STACKS..]' ], 'Synthesizes and prints the CloudFormation template for this stack', yargs => yargs
.option('interactive', { type: 'boolean', alias: 'i', desc: 'interactively watch and show template updates' })
.option('output', { type: 'string', alias: 'o', desc: 'write CloudFormation template for requested stacks to the given directory' }))
Expand All @@ -65,9 +62,7 @@ async function parseCommandLineArguments() {
.command('diff [STACK]', 'Compares the specified stack with the deployed stack or a local template file', yargs => yargs
.option('template', { type: 'string', desc: 'the path to the CloudFormation template to compare with' }))
.command('metadata [STACK]', 'Returns all metadata associated with this stack')
// tslint:disable-next-line:max-line-length
.command('init [TEMPLATE]', 'Create a new, empty CDK project from a template. Invoked without TEMPLATE, the app template will be used.', yargs => yargs
// tslint:disable-next-line:max-line-length
.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 })
.option('list', { type: 'boolean', desc: 'list the available templates' }))
.commandDir('../lib/commands', { exclude: /^_.*/, visit: decorateCommand })
Expand All @@ -80,7 +75,7 @@ async function parseCommandLineArguments() {
].join('\n\n'))
.argv;
}
// tslint:enable:no-shadowed-variable
// tslint:enable:no-shadowed-variable max-line-length

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

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

const aws = new SDK(argv.profile, argv.proxy);
const aws = new SDK({
profile: argv.profile,
proxyAddress: argv.proxy,
instance: argv.instance,
});

const availableContextProviders: contextplugins.ProviderMap = {
'availability-zones': new contextplugins.AZContextProviderPlugin(aws),
Expand Down
135 changes: 117 additions & 18 deletions packages/aws-cdk/lib/api/util/sdk.ts
@@ -1,14 +1,41 @@
import { Environment} from '@aws-cdk/cx-api';
import AWS = require('aws-sdk');
import child_process = require('child_process');
import fs = require('fs-extra');
import os = require('os');
import path = require('path');
import util = require('util');
import { debug } from '../../logging';
import { PluginHost } from '../../plugin';
import { CredentialProviderSource, Mode } from '../aws-auth/credentials';
import { AccountAccessKeyCache } from './account-cache';
import { SharedIniFile } from './sdk_ini_file';

export interface SDKOptions {
/**
* Profile name to use
*
* @default No profile
*/
profile?: string;

/**
* Proxy address to use
*
* @default No proxy
*/
proxyAddress?: string;

/**
* Whether we should try instance credentials
*
* True/false to force/disable. Default is to guess.
*
* @default Automatically determine.
*/
instance?: boolean;
}

/**
* Source for SDK client objects
*
Expand All @@ -22,22 +49,25 @@ export class SDK {
private readonly defaultAwsAccount: DefaultAWSAccount;
private readonly credentialsCache: CredentialsCache;
private readonly defaultClientArgs: any = {};
private readonly profile?: string;

constructor(private readonly profile: string | undefined, proxyAddress: string | undefined) {
const defaultCredentialProvider = makeCLICompatibleCredentialProvider(profile);
constructor(options: SDKOptions) {
this.profile = options.profile;

const defaultCredentialProvider = makeCLICompatibleCredentialProvider(options.profile, options.instance);

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

// https://aws.amazon.com/blogs/developer/using-the-aws-sdk-for-javascript-from-behind-a-proxy/
if (proxyAddress === undefined) {
proxyAddress = httpsProxyFromEnvironment();
if (options.proxyAddress === undefined) {
options.proxyAddress = httpsProxyFromEnvironment();
}
if (proxyAddress) { // Ignore empty string on purpose
debug('Using proxy server: %s', proxyAddress);
if (options.proxyAddress) { // Ignore empty string on purpose
debug('Using proxy server: %s', options.proxyAddress);
this.defaultClientArgs.httpOptions = {
agent: require('proxy-agent')(proxyAddress)
agent: require('proxy-agent')(options.proxyAddress)
};
}

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

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

return new AWS.CredentialProviderChain([
const sources = [
() => new AWS.EnvironmentCredentials('AWS'),
() => new AWS.EnvironmentCredentials('AMAZON'),
...(await fs.pathExists(filename) ? [() => new AWS.SharedIniFileCredentials({ profile, filename })] : []),
() => {
// Calling private API
if ((AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials()) {
return new AWS.ECSCredentials();
}
return new AWS.EC2MetadataCredentials();
];
if (fs.pathExists(filename)) {
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename }));
}

if (hasEcsCredentials()) {
sources.push(() => new AWS.ECSCredentials());
} else {
// else if: don't get EC2 creds if we should have gotten ECS creds--ECS instances also
// run on EC2 boxes but the creds represent something different. Same behavior as
// upstream code.

if (instance === undefined) { instance = await hasEc2Credentials(); }

if (instance) {
sources.push(() => new AWS.EC2MetadataCredentials());
}
]);
}

return new AWS.CredentialProviderChain(sources);
}

/**
Expand Down Expand Up @@ -290,4 +331,62 @@ function httpsProxyFromEnvironment(): string | undefined {
return process.env.HTTPS_PROXY;
}
return undefined;
}
}

/**
* Return whether it looks like we'll have ECS credentials available
*/
function hasEcsCredentials() {
return (AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials();
}

/**
* Return whether we're on an EC2 instance
*/
async function hasEc2Credentials() {
debug("Determining whether we're on an EC2 instance.");

let instance = false;
if (process.platform === 'win32') {
// https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/identify_ec2_instances.html
const result = await util.promisify(child_process.exec)('wmic path win32_computersystemproduct get uuid', { encoding: 'utf-8' });
// output looks like
// UUID
// EC2AE145-D1DC-13B2-94ED-01234ABCDEF
const lines = result.stdout.toString().split('\n');
instance = lines.some(x => matchesRegex(/^ec2/i, x));
} else {
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/identify_ec2_instances.html
const files: Array<[string, RegExp]> = [
// This recognizes the Xen hypervisor based instances (pre-5th gen)
['/sys/hypervisor/uuid', /^ec2/i],

// This recognizes the new Hypervisor (5th-gen instances and higher)
// Can't use the advertised file '/sys/devices/virtual/dmi/id/product_uuid' because it requires root to read.
['/sys/devices/virtual/dmi/id/sys_vendor', /ec2/i],
];
for (const [file, re] of files) {
if (matchesRegex(re, await readIfPossible(file))) {
instance = true;
break;
}
}
}

debug(instance ? 'Looks like EC2 instance.' : 'Does not look like EC2 instance.');
return instance;
}

async function readIfPossible(filename: string): Promise<string | undefined> {
try {
if (!await fs.pathExists(filename)) { return undefined; }
return fs.readFile(filename, { encoding: 'utf-8' });
} catch (e) {
debug(e);
return undefined;
}
}

function matchesRegex(re: RegExp, s: string | undefined) {
return s !== undefined && re.exec(s) !== null;
}

0 comments on commit 2acb0a9

Please sign in to comment.