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; +}