Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aws-cdk): Detect presence of EC2 credentials #724

Merged
merged 1 commit into from
Sep 17, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

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('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' }))
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,
ec2creds: argv.ec2creds,
});

const availableContextProviders: contextplugins.ProviderMap = {
'availability-zones': new contextplugins.AZContextProviderPlugin(aws),
Expand Down
136 changes: 118 additions & 18 deletions packages/aws-cdk/lib/api/util/sdk.ts
Original file line number Diff line number Diff line change
@@ -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
*
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.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)
};
}

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, 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);
}

/**
Expand Down Expand Up @@ -290,4 +331,63 @@ 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet, some more esoteric code of unholy nature doing something that the JS SDK should expose more directly \o/

}

/**
* Return whether we're on an EC2 instance
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reference somewhere for an AWS tool that does this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EC2 documentation mentions those!

*/
async function hasEc2Credentials() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely, this is not something that could be included in all the SDKs, right? 🤨

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably most other ones have semi-sane behavior in this regard already.

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