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(cli): automatically determine region on EC2 instances #9313

Merged
merged 16 commits into from
Aug 25, 2020
Merged
Show file tree
Hide file tree
Changes from 15 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
182 changes: 136 additions & 46 deletions packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,9 @@ export class AwsCliCompatible {
* 3. Respects $AWS_SHARED_CREDENTIALS_FILE.
* 4. Respects $AWS_DEFAULT_PROFILE in addition to $AWS_PROFILE.
*/
public static async credentialChain(
profile: string | undefined,
ec2creds: boolean | undefined,
containerCreds: boolean | undefined,
httpOptions: AWS.HTTPOptions | undefined) {
public static async credentialChain(options: CredentialChainOptions = {}) {

profile = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
const profile = options.profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';

const sources = [
() => new AWS.EnvironmentCredentials('AWS'),
Expand All @@ -48,12 +44,17 @@ export class AwsCliCompatible {
// Force reading the `config` file if it exists by setting the appropriate
// environment variable.
await forceSdkToReadConfigIfPresent();
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions, tokenCodeFn }));
sources.push(() => new AWS.SharedIniFileCredentials({
profile,
filename: credentialsFileName(),
httpOptions: options.httpOptions,
tokenCodeFn,
}));
}

if (containerCreds ?? hasEcsCredentials()) {
if (options.containerCreds ?? hasEcsCredentials()) {
sources.push(() => new AWS.ECSCredentials());
} else if (ec2creds ?? await hasEc2Credentials()) {
} else if (options.ec2instance ?? await isEc2Instance()) {
// 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.
Expand All @@ -75,11 +76,9 @@ export class AwsCliCompatible {
* 2. $AWS_DEFAULT_PROFILE and $AWS_DEFAULT_REGION are also respected.
*
* Lambda and CodeBuild set the $AWS_REGION variable.
*
* FIXME: EC2 instances require querying the metadata service to determine the current region.
*/
public static async region(profile: string | undefined): Promise<string> {
profile = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
public static async region(options: RegionOptions = {}): Promise<string> {
const profile = options.profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';

// Defaults inside constructor
const toCheck = [
Expand All @@ -92,14 +91,36 @@ export class AwsCliCompatible {
process.env.AWS_DEFAULT_REGION || process.env.AMAZON_DEFAULT_REGION;

while (!region && toCheck.length > 0) {
const options = toCheck.shift()!;
if (await fs.pathExists(options.filename)) {
const configFile = new SharedIniFile(options);
const section = await configFile.getProfile(options.profile);
const opts = toCheck.shift()!;
if (await fs.pathExists(opts.filename)) {
const configFile = new SharedIniFile(opts);
const section = await configFile.getProfile(opts.profile);
region = section?.region;
}
}

if (!region && (options.ec2instance ?? await isEc2Instance())) {
debug('Looking up AWS region in the EC2 Instance Metadata Service (IMDS).');
const imdsOptions = {
httpOptions: { timeout: 1000, connectTimeout: 1000 }, maxRetries: 2,
};
const metadataService = new AWS.MetadataService(imdsOptions);

let token;
try {
token = await getImdsV2Token(metadataService);
} catch (e) {
debug(`No IMDSv2 token: ${e}`);
}

try {
region = await getRegionFromImds(metadataService, token);
debug(`AWS region from IMDS: ${region}`);
} catch (e) {
debug(`Unable to retrieve AWS region from IMDS: ${e}`);
}
}

if (!region) {
const usedProfile = !profile ? '' : ` (profile: "${profile}")`;
region = 'us-east-1'; // This is what the AWS CLI does
Expand All @@ -120,39 +141,96 @@ function hasEcsCredentials(): boolean {
/**
* 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, readIfPossible(file))) {
instance = true;
break;
async function isEc2Instance() {
if (isEc2InstanceCache === undefined) {
debug("Determining if 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]> = [
Comment on lines +144 to +158
Copy link
Contributor

Choose a reason for hiding this comment

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

consider adding similar test cases for this method and its conditionals. Since this method already existed, I'll let you make the final call.

// 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, readIfPossible(file))) {
instance = true;
break;
}
}
}
debug(instance ? 'Looks like an EC2 instance.' : 'Does not look like an EC2 instance.');
isEc2InstanceCache = instance;
}
return isEc2InstanceCache;
}


let isEc2InstanceCache: boolean | undefined = undefined;

debug(instance ? 'Looks like EC2 instance.' : 'Does not look like EC2 instance.');
return instance;
/**
* Attempts to get a Instance Metadata Service V2 token
*/
async function getImdsV2Token(metadataService: AWS.MetadataService): Promise<string> {
debug('Attempting to retrieve an IMDSv2 token.');
return new Promise((resolve, reject) => {
metadataService.request(
'/latest/api/token',
{
method: 'PUT',
headers: { 'x-aws-ec2-metadata-token-ttl-seconds': '60' },
},
(err: AWS.AWSError, token: string | undefined) => {
if (err) {
reject(err);
} else if (!token) {
reject(new Error('IMDS did not return a token.'));
} else {
resolve(token);
}
});
});
}

/**
* Attempts to get the region from the Instance Metadata Service
*/
async function getRegionFromImds(metadataService: AWS.MetadataService, token: string | undefined): Promise<string> {
debug('Retrieving the AWS region from the IMDS.');
let options: { method?: string | undefined; headers?: { [key: string]: string; } | undefined; } = {};
if (token) {
options = { headers: { 'x-aws-ec2-metadata-token': token } };
}
return new Promise((resolve, reject) => {
metadataService.request(
'/latest/dynamic/instance-identity/document',
options,
(err: AWS.AWSError, instanceIdentityDocument: string | undefined) => {
if (err) {
reject(err);
} else if (!instanceIdentityDocument) {
reject(new Error('IMDS did not return an Instance Identity Document.'));
} else {
try {
resolve(JSON.parse(instanceIdentityDocument).region);
} catch (e) {
reject(e);
}
}
});
});
}

function homeDir() {
Expand Down Expand Up @@ -201,6 +279,18 @@ function readIfPossible(filename: string): string | undefined {
}
}

export interface CredentialChainOptions {
readonly profile?: string;
readonly ec2instance?: boolean;
readonly containerCreds?: boolean;
readonly httpOptions?: AWS.HTTPOptions;
}

export interface RegionOptions {
readonly profile?: string;
readonly ec2instance?: boolean;
}

/**
* Ask user for MFA token for given serial
*
Expand Down
12 changes: 10 additions & 2 deletions packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,16 @@ export class SdkProvider {
public static async withAwsCliCompatibleDefaults(options: SdkProviderOptions = {}) {
const sdkOptions = parseHttpOptions(options.httpOptions ?? {});

const chain = await AwsCliCompatible.credentialChain(options.profile, options.ec2creds, options.containerCreds, sdkOptions.httpOptions);
const region = await AwsCliCompatible.region(options.profile);
const chain = await AwsCliCompatible.credentialChain({
profile: options.profile,
ec2instance: options.ec2creds,
containerCreds: options.containerCreds,
httpOptions: sdkOptions.httpOptions,
});
const region = await AwsCliCompatible.region({
profile: options.profile,
ec2instance: options.ec2creds,
});

return new SdkProvider(chain, region, sdkOptions);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/aws-cdk/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import { CloudFormationStackArtifact } from '@aws-cdk/cx-api';
import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments';
import { CdkToolkit } from '../lib/cdk-toolkit';
import { classMockOf, MockCloudExecutable } from './util';
import { instanceMockFrom, MockCloudExecutable } from './util';

let cloudExecutable: MockCloudExecutable;
let cloudFormation: jest.Mocked<CloudFormationDeployments>;
Expand Down Expand Up @@ -39,7 +39,7 @@ beforeEach(() => {
}],
});

cloudFormation = classMockOf(CloudFormationDeployments);
cloudFormation = instanceMockFrom(CloudFormationDeployments);

toolkit = new CdkToolkit({
cloudExecutable,
Expand Down
29 changes: 28 additions & 1 deletion packages/aws-cdk/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,37 @@ export function testStack(stack: TestStackArtifact) {
* automatic detection of properties (as those exist on instances, not
* classes).
*/
export function classMockOf<A>(ctr: new (...args: any[]) => A): jest.Mocked<A> {
export function instanceMockFrom<A>(ctr: new (...args: any[]) => A): jest.Mocked<A> {
const ret: any = {};
for (const methodName of Object.getOwnPropertyNames(ctr.prototype)) {
ret[methodName] = jest.fn();
}
return ret;
}

/**
* Run an async block with a class (constructor) replaced with a mock
*
* The class constructor will be replaced with a constructor that returns
* a singleton, and the singleton will be passed to the block so that its
* methods can be mocked individually.
*
* Uses `instanceMockFrom` so is subject to the same limitations that hold
* for that function.
*/
export async function withMockedClassSingleton<A extends object, K extends keyof A, B>(
obj: A,
key: K,
cb: (mock: A[K] extends jest.Constructable ? jest.Mocked<InstanceType<A[K]>> : never) => Promise<B>,
): Promise<B> {

const original = obj[key];
try {
const mock = instanceMockFrom(original as any);
obj[key] = jest.fn().mockReturnValue(mock) as any;
const ret = await cb(mock as any);
return ret;
} finally {
obj[key] = original;
}
}
29 changes: 29 additions & 0 deletions packages/aws-cdk/test/util/awscli-compatible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as AWS from 'aws-sdk';
import { AwsCliCompatible } from '../../lib/api/aws-auth/awscli-compatible';
import { withMockedClassSingleton } from '../util';

beforeEach(() => {
// Set to paths that don't exist so the SDK doesn't accidentally load this config
process.env.AWS_CONFIG_FILE = '/home/dummydummy/.bxt/config';
process.env.AWS_SHARED_CREDENTIALS_FILE = '/home/dummydummy/.bxt/credentials';
// Scrub some environment variables that might be set if we're running on CodeBuild which will interfere with the tests.
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
});

test('on an EC2 instance, region lookup queries IMDS', async () => {
return withMockedClassSingleton(AWS, 'MetadataService', async (mdService) => {
mdService.request
// First call for a token
.mockImplementationOnce((_1, _2, cb) => { cb(undefined as any, 'token'); })
// Second call for the region
.mockImplementationOnce((_1, _2, cb) => { cb(undefined as any, JSON.stringify({ region: 'some-region' })); });

const region = await AwsCliCompatible.region({ ec2instance: true });
expect(region).toEqual('some-region');
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: add an expectation that the mocks were actually called

});
});