From 8565b64b104464f965ff42afe2e93992d1271ed5 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 17 Sep 2018 11:44:48 +0200 Subject: [PATCH] feat(aws-cdk): Detect presence of EC2 credentials 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. --- packages/aws-cdk/bin/cdk.ts | 17 ++-- packages/aws-cdk/lib/api/util/sdk.ts | 136 +++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 27 deletions(-) diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 9b83f1b2b1dbd..7bdbbe2c2c0e8 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -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 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' }) @@ -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('ec2creds', { 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' })) @@ -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 }) @@ -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 @@ -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, + ec2creds: argv.ec2creds, + }); const availableContextProviders: contextplugins.ProviderMap = { 'availability-zones': new contextplugins.AZContextProviderPlugin(aws), diff --git a/packages/aws-cdk/lib/api/util/sdk.ts b/packages/aws-cdk/lib/api/util/sdk.ts index ff4f4a33493a4..56036ae896b49 100644 --- a/packages/aws-cdk/lib/api/util/sdk.ts +++ b/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. + */ + ec2creds?: boolean; +} + /** * Source for SDK client objects * @@ -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.ec2creds); // 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) }; } @@ -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, ec2creds: 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 (ec2creds === undefined) { ec2creds = await hasEc2Credentials(); } + + if (ec2creds) { + sources.push(() => new AWS.EC2MetadataCredentials()); } - ]); + } + + return new AWS.CredentialProviderChain(sources); } /** @@ -290,4 +331,63 @@ function httpsProxyFromEnvironment(): string | undefined { return process.env.HTTPS_PROXY; } return undefined; -} \ No newline at end of file +} + +/** + * 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. + // Instead, sys_vendor contains something like 'Amazon EC2'. + ['/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 { + 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; +}