diff --git a/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts b/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts index 90c0a6bc67c30..6d85e08310128 100644 --- a/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts +++ b/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts @@ -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'), @@ -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. @@ -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 { - profile = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default'; + public static async region(options: RegionOptions = {}): Promise { + const profile = options.profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default'; // Defaults inside constructor const toCheck = [ @@ -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 @@ -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]> = [ + // 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 { + 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 { + 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() { @@ -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 * diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts index ab583611bbc52..6ea65afece5e7 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts @@ -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); } diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index b283b884b6a4f..b5bd12ac0472f 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -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; @@ -39,7 +39,7 @@ beforeEach(() => { }], }); - cloudFormation = classMockOf(CloudFormationDeployments); + cloudFormation = instanceMockFrom(CloudFormationDeployments); toolkit = new CdkToolkit({ cloudExecutable, diff --git a/packages/aws-cdk/test/util.ts b/packages/aws-cdk/test/util.ts index f6966e8737711..f21f3c4abd8d3 100644 --- a/packages/aws-cdk/test/util.ts +++ b/packages/aws-cdk/test/util.ts @@ -126,10 +126,37 @@ export function testStack(stack: TestStackArtifact) { * automatic detection of properties (as those exist on instances, not * classes). */ -export function classMockOf(ctr: new (...args: any[]) => A): jest.Mocked { +export function instanceMockFrom(ctr: new (...args: any[]) => A): jest.Mocked { 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( + obj: A, + key: K, + cb: (mock: A[K] extends jest.Constructable ? jest.Mocked> : never) => Promise, +): Promise { + + 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; + } +} \ No newline at end of file diff --git a/packages/aws-cdk/test/util/awscli-compatible.test.ts b/packages/aws-cdk/test/util/awscli-compatible.test.ts new file mode 100644 index 0000000000000..42cfc37e54c30 --- /dev/null +++ b/packages/aws-cdk/test/util/awscli-compatible.test.ts @@ -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'); + }); +}); +