Skip to content

Commit

Permalink
Toolkit: support profiles (#517)
Browse files Browse the repository at this point in the history
This adds support for AWS profiles to the CDK toolkit.

At the same time, it overhauls how the AWS SDK is configured. The
configuration via environment variables set at just the right time
is removed, and we reimplement some parts of the SDK in an
AWS CLI-compatible way to get a consistent view on the account ID
and region based on the provided configuration.

Fixes a bug in the AWS STS call where it would do two default
credential lookups (down to one now).

Fixes #480.
  • Loading branch information
rix0rrr authored Aug 7, 2018
1 parent 42b108d commit 6846b60
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 68 deletions.
6 changes: 2 additions & 4 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
#!/usr/bin/env node
import 'source-map-support/register';

// Ensure the AWS SDK is properly initialized before anything else.
import '../lib/api/util/sdk-load-aws-config';

import cxapi = require('@aws-cdk/cx-api');
import cdkUtil = require('@aws-cdk/util');
import childProcess = require('child_process');
Expand Down Expand Up @@ -49,6 +46,7 @@ async function parseCommandLineArguments() {
.option('ignore-errors', { type: 'boolean', default: false, desc: 'Ignores synthesis errors, which will likely produce an invalid output' })
.option('json', { type: 'boolean', alias: 'j', desc: 'Use JSON output instead of YAML' })
.option('verbose', { type: 'boolean', alias: 'v', desc: 'Show debug logs' })
.option('profile', { type: 'string', desc: 'Use the indicated AWS profile as the default environment' })
// tslint:disable-next-line:max-line-length
.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
Expand Down Expand Up @@ -110,7 +108,7 @@ async function initCommandLine() {

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

const aws = new SDK();
const aws = new SDK(argv.profile);

const availableContextProviders: contextplugins.ProviderMap = {
'availability-zones': new contextplugins.AZContextProviderPlugin(aws),
Expand Down
4 changes: 2 additions & 2 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface DeployStackResult {
}

export async function deployStack(stack: cxapi.SynthesizedStack,
sdk: SDK = new SDK(),
sdk: SDK,
toolkitInfo?: ToolkitInfo,
deployName?: string,
quiet: boolean = false): Promise<DeployStackResult> {
Expand Down Expand Up @@ -134,7 +134,7 @@ async function makeBodyParameter(stack: cxapi.SynthesizedStack, toolkitInfo?: To
}
}

export async function destroyStack(stack: cxapi.StackInfo, sdk: SDK = new SDK(), deployName?: string, quiet: boolean = false) {
export async function destroyStack(stack: cxapi.StackInfo, sdk: SDK, deployName?: string, quiet: boolean = false) {
if (!stack.environment) {
throw new Error(`The stack ${stack.name} does not have an environment`);
}
Expand Down
45 changes: 0 additions & 45 deletions packages/aws-cdk/lib/api/util/sdk-load-aws-config.ts

This file was deleted.

106 changes: 89 additions & 17 deletions packages/aws-cdk/lib/api/util/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Environment} from '@aws-cdk/cx-api';
import { CloudFormation, config, CredentialProviderChain, EC2, S3, SSM, STS } from 'aws-sdk';
import AWS = require('aws-sdk');
import os = require('os');
import path = require('path');
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';

/**
* Source for SDK client objects
Expand All @@ -19,50 +22,52 @@ export class SDK {
private defaultAccountId?: string = undefined;
private readonly userAgent: string;
private readonly accountCache = new AccountAccessKeyCache();
private readonly defaultCredentialProvider: AWS.CredentialProviderChain;

constructor() {
constructor(private readonly profile: string | undefined) {
// Find the package.json from the main toolkit
const pkg = (require.main as any).require('../package.json');
this.userAgent = `${pkg.name}/${pkg.version}`;
this.defaultCredentialProvider = makeCLICompatibleCredentialProvider(profile);
}

public async cloudFormation(environment: Environment, mode: Mode): Promise<CloudFormation> {
return new CloudFormation({
public async cloudFormation(environment: Environment, mode: Mode): Promise<AWS.CloudFormation> {
return new AWS.CloudFormation({
region: environment.region,
credentialProvider: await this.getCredentialProvider(environment.account, mode),
customUserAgent: this.userAgent
});
}

public async ec2(awsAccountId: string | undefined, region: string | undefined, mode: Mode): Promise<EC2> {
return new EC2({
public async ec2(awsAccountId: string | undefined, region: string | undefined, mode: Mode): Promise<AWS.EC2> {
return new AWS.EC2({
region,
credentialProvider: await this.getCredentialProvider(awsAccountId, mode),
customUserAgent: this.userAgent
});
}

public async ssm(awsAccountId: string | undefined, region: string | undefined, mode: Mode): Promise<SSM> {
return new SSM({
public async ssm(awsAccountId: string | undefined, region: string | undefined, mode: Mode): Promise<AWS.SSM> {
return new AWS.SSM({
region,
credentialProvider: await this.getCredentialProvider(awsAccountId, mode),
customUserAgent: this.userAgent
});
}

public async s3(environment: Environment, mode: Mode): Promise<S3> {
return new S3({
public async s3(environment: Environment, mode: Mode): Promise<AWS.S3> {
return new AWS.S3({
region: environment.region,
credentialProvider: await this.getCredentialProvider(environment.account, mode),
customUserAgent: this.userAgent
});
}

public defaultRegion() {
return config.region;
public defaultRegion(): string | undefined {
return getCLICompatibleDefaultRegion(this.profile);
}

public async defaultAccount() {
public async defaultAccount(): Promise<string | undefined> {
if (!this.defaultAccountFetched) {
this.defaultAccountId = await this.lookupDefaultAccount();
this.defaultAccountFetched = true;
Expand All @@ -73,8 +78,7 @@ export class SDK {
private async lookupDefaultAccount() {
try {
debug('Resolving default credentials');
const chain = new CredentialProviderChain();
const creds = await chain.resolvePromise();
const creds = await this.defaultCredentialProvider.resolvePromise();
const accessKeyId = creds.accessKeyId;
if (!accessKeyId) {
throw new Error('Unable to resolve AWS credentials (setup with "aws configure")');
Expand All @@ -83,7 +87,7 @@ export class SDK {
const accountId = await this.accountCache.fetch(creds.accessKeyId, async () => {
// if we don't have one, resolve from STS and store in cache.
debug('Looking up default account ID from STS');
const result = await new STS().getCallerIdentity().promise();
const result = await new AWS.STS({ credentials: creds }).getCallerIdentity().promise();
const aid = result.Account;
if (!aid) {
debug('STS didn\'t return an account ID');
Expand All @@ -100,7 +104,7 @@ export class SDK {
}
}

private async getCredentialProvider(awsAccountId: string | undefined, mode: Mode): Promise<CredentialProviderChain | undefined> {
private async getCredentialProvider(awsAccountId: string | undefined, mode: Mode): Promise<AWS.CredentialProviderChain | undefined> {
// If requested account is undefined or equal to default account, use default credentials provider.
const defaultAccount = await this.defaultAccount();
if (!awsAccountId || awsAccountId === defaultAccount) {
Expand Down Expand Up @@ -129,3 +133,71 @@ export class SDK {
throw new Error(`Need to perform AWS calls for account ${awsAccountId}, but no credentials found. Tried: ${sourceNames}.`);
}
}

/**
* Build an AWS CLI-compatible credential chain provider
*
* This is similar to the default credential provider chain created by the SDK
* except it also accepts the profile argument in the constructor (not just from
* the environment).
*
* To mimic the AWS CLI behavior:
*
* - we default to ~/.aws/credentials if environment variable for credentials
* 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).
*/
function makeCLICompatibleCredentialProvider(profile: string | 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([
() => new AWS.EnvironmentCredentials('AWS'),
() => new AWS.EnvironmentCredentials('AMAZON'),
() => new AWS.SharedIniFileCredentials({ profile, filename }),
() => {
// Calling private API
if ((AWS.ECSCredentials.prototype as any).isConfiguredForEcsCredentials()) {
return new AWS.ECSCredentials();
}
return new AWS.EC2MetadataCredentials();
}
]);
}

/**
* Return the default region in a CLI-compatible way
*
* Mostly copied from node_loader.js, but with the following differences:
*
* - Takes a runtime profile name to load the region from, not just based on environment
* variables at process start.
* - We have needed to create a local copy of the SharedIniFile class because the
* implementation in 'aws-sdk' is private (and the default use of it in the
* SDK does not allow us to specify a profile at runtime).
* - AWS_DEFAULT_PROFILE and AWS_DEFAULT_REGION are also used as environment
* variables to be used to determine the region.
*/
function getCLICompatibleDefaultRegion(profile: string | undefined): string | undefined {
profile = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';

// Defaults inside constructor
const toCheck = [
{filename: process.env.AWS_SHARED_CREDENTIALS_FILE },
{isConfig: true, filename: process.env.AWS_CONFIG_FILE},
];

let region = process.env.AWS_REGION || process.env.AMAZON_REGION ||
process.env.AWS_DEFAULT_REGION || process.env.AMAZON_DEFAULT_REGION;

while (!region && toCheck.length > 0) {
const configFile = new SharedIniFile(toCheck.shift());
const section = configFile.getProfile(profile);
region = section && section.region;
}

return region;
}
52 changes: 52 additions & 0 deletions packages/aws-cdk/lib/api/util/sdk_ini_file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* A reimplementation of JS AWS SDK's SharedIniFile class
*
* We need that class to parse the ~/.aws/config file to determine the correct
* region at runtime, but unfortunately it is private upstream.
*/

import AWS = require('aws-sdk');
import os = require('os');
import path = require('path');

export interface SharedIniFileOptions {
isConfig?: boolean;
filename?: string;
}

export class SharedIniFile {
private readonly isConfig: boolean;
private readonly filename: string;
private parsedContents?: {[key: string]: {[key: string]: string}};

constructor(options?: SharedIniFileOptions) {
options = options || {};
this.isConfig = options.isConfig === true;
this.filename = options.filename || this.getDefaultFilepath();
}

public getProfile(profile: string) {
this.ensureFileLoaded();

const profileIndex = profile !== (AWS as any).util.defaultProfile && this.isConfig ?
'profile ' + profile : profile;

return this.parsedContents![profileIndex];
}

private getDefaultFilepath(): string {
return path.join(
os.homedir(),
'.aws',
this.isConfig ? 'config' : 'credentials'
);
}

private ensureFileLoaded() {
if (!this.parsedContents) {
this.parsedContents = (AWS as any).util.ini.parse(
(AWS as any).util.readFileSync(this.filename)
);
}
}
}

0 comments on commit 6846b60

Please sign in to comment.