Skip to content

Commit

Permalink
chore(cli): CLI uses cdk-assets to upload templates and assets (aws…
Browse files Browse the repository at this point in the history
…#6565)

* chore(cli): CLI uses `cdk-assets` to upload templates and assets

Centralize all logic about how templates, files and container assets
are built and uploaded in the `cdk-assets` tool.

We need this change first because it's on the critical path of the CLI
being able to deploy using the new convention mode roles (which requires
the CLI to use the asset publishing role for uploading the
CloudFormation templates), which is required for the pipeline
being able to do self-mutation using `cdk deploy`.

We can roll this change out independently of the framework emitting
the asset manifest (the CLI can generate it) and we don't need
to assume any roles, while we still get to test the new code path.

Also importing a number of improvements to `cdk-assets` from the
proof-of-concept branch.

* WIP

* Bring in partition querying from CDK feature branch

* Migrate bugfix from feat/convmode

* Make a distinction between an SdkProvider and an SDK

* Make docker integ tests actually do something

* Add tests for SDK Provider

* Fix cdk-assets tests, use 'upload' instead of 'putObject'

* Newlines

* Purge environment variables that will mess with auth tests

* Back to stable, accept breaking change

* Remove ECR repository name output again, it's not necessary

* Remove remaining rejected patch files

* Fix build, we don't need repositoryName

* Disable container creds, maybe that helps on CodeBuild

* CHeck for file existence

* Inspect environment before failing

* 'delete' instead of assigning 'undefined'

* Respect AWS_CONFIG_FILE variable when setting AWS_SDK_LOAD_CONFIG

* Review comments

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
rix0rrr and mergify[bot] authored Mar 11, 2020
1 parent 37144fb commit d557592
Show file tree
Hide file tree
Showing 86 changed files with 1,905 additions and 2,246 deletions.
2 changes: 1 addition & 1 deletion allowed-breaking-changes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ change-return-type:@aws-cdk/aws-lambda-destinations.EventBridgeDestination.bind
change-return-type:@aws-cdk/aws-lambda-destinations.LambdaDestination.bind
change-return-type:@aws-cdk/aws-lambda-destinations.SnsDestination.bind
change-return-type:@aws-cdk/aws-lambda-destinations.SqsDestination.bind

removed:@aws-cdk/cdk-assets-schema.DockerImageDestination.imageUri
1 change: 0 additions & 1 deletion packages/@aws-cdk/cdk-assets-schema/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# cdk-assets-schema

<!--BEGIN STABILITY BANNER-->

---
Expand Down
19 changes: 19 additions & 0 deletions packages/@aws-cdk/cdk-assets-schema/lib/aws-destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,24 @@ export interface AwsDestination {
* @default - No ExternalId will be supplied
*/
readonly assumeRoleExternalId?: string;
}

/**
* Placeholders which can be used in the destinations
*/
export class Placeholders {
/**
* Insert this into the destination fields to be replaced with the current region
*/
public static readonly CURRENT_REGION = '${AWS::Region}';

/**
* Insert this into the destination fields to be replaced with the current account
*/
public static readonly CURRENT_ACCOUNT = '${AWS::AccountId}';

/**
* Insert this into the destination fields to be replaced with the current partition
*/
public static readonly CURRENT_PARTITION = '${AWS::Partition}';
}
11 changes: 0 additions & 11 deletions packages/@aws-cdk/cdk-assets-schema/lib/docker-image-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,4 @@ export interface DockerImageDestination extends AwsDestination {
* Tag of the image to publish
*/
readonly imageTag: string;

/**
* Full Docker tag coordinates (registry and repository and tag)
*
* Example:
*
* ```
* 1234.dkr.ecr.REGION.amazonaws.com/REPO:TAG
* ```
*/
readonly imageUri: string;
}
3 changes: 1 addition & 2 deletions packages/@aws-cdk/cdk-assets-schema/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ function isDockerImageAsset(entry: object): DockerImageAsset {
expectKey(destination, 'assumeRoleExternalId', isString, true);
expectKey(destination, 'repositoryName', isString);
expectKey(destination, 'imageTag', isString);
expectKey(destination, 'imageUri', isString);
return destination;
}));

return entry;
}
}
3 changes: 1 addition & 2 deletions packages/@aws-cdk/cdk-assets-schema/test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ test('Correctly validate Docker image asset', () => {
region: 'us-north-20',
repositoryName: 'REPO',
imageTag: 'TAG',
imageUri: 'URI',
},
},
},
Expand Down Expand Up @@ -79,4 +78,4 @@ test('Throw on invalid file asset', () => {
},
});
}).toThrow(/Expected a string, got '3'/);
});
});
11 changes: 7 additions & 4 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import * as colors from 'colors/safe';
import * as path from 'path';
import * as yargs from 'yargs';

import { bootstrapEnvironment, BootstrapEnvironmentProps, SDK } from '../lib';
import { bootstrapEnvironment, BootstrapEnvironmentProps } from '../lib';
import { SdkProvider } from '../lib/api/aws-auth';
import { bootstrapEnvironment2 } from '../lib/api/bootstrap/bootstrap-environment2';
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
import { execProgram } from '../lib/api/cxapp/exec';
Expand Down Expand Up @@ -111,11 +112,13 @@ async function initCommandLine() {
debug('CDK toolkit version:', version.DISPLAY_VERSION);
debug('Command line arguments:', argv);

const aws = new SDK({
const aws = await SdkProvider.withAwsCliCompatibleDefaults({
profile: argv.profile,
proxyAddress: argv.proxy,
caBundlePath: argv['ca-bundle-path'],
ec2creds: argv.ec2creds,
httpOptions: {
proxyAddress: argv.proxy,
caBundlePath: argv['ca-bundle-path'],
}
});

const configuration = new Configuration(argv);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { debug } from '../../logging';
import { Account } from './sdk-provider';

/**
* Disk cache which maps access key IDs to account IDs.
Expand All @@ -21,7 +22,7 @@ export class AccountAccessKeyCache {
* @param filePath Path to the cache file
*/
constructor(filePath?: string) {
this.cacheFile = filePath || path.join(os.homedir(), '.cdk', 'cache', 'accounts.json');
this.cacheFile = filePath || path.join(os.homedir(), '.cdk', 'cache', 'accounts_partitions.json');
}

/**
Expand All @@ -38,51 +39,52 @@ export class AccountAccessKeyCache {
* @param accessKeyId
* @param resolver
*/
public async fetch(accessKeyId: string, resolver: () => Promise<string | undefined>) {
public async fetch(accessKeyId: string, resolver: () => Promise<Account | undefined>) {
// try to get account ID based on this access key ID from disk.
const cached = await this.get(accessKeyId);
if (cached) {
debug(`Retrieved account ID ${cached} from disk cache`);

debug(`Retrieved account ID ${cached.accountId} from disk cache`);
return cached;
}

// if it's not in the cache, resolve and put in cache.
const accountId = await resolver();
if (accountId) {
await this.put(accessKeyId, accountId);
const account = await resolver();
if (account) {
await this.put(accessKeyId, account);
}

return accountId;
return account;
}

/** Get the account ID from an access key or undefined if not in cache */
public async get(accessKeyId: string): Promise<string | undefined> {
public async get(accessKeyId: string): Promise<Account | undefined> {
const map = await this.loadMap();
return map[accessKeyId];
}

/** Put a mapping betweenn access key and account ID */
public async put(accessKeyId: string, accountId: string) {
public async put(accessKeyId: string, account: Account) {
let map = await this.loadMap();

// nuke cache if it's too big.
if (Object.keys(map).length >= AccountAccessKeyCache.MAX_ENTRIES) {
map = { };
}

map[accessKeyId] = accountId;
map[accessKeyId] = account;
await this.saveMap(map);
}

private async loadMap(): Promise<{ [accessKeyId: string]: string }> {
private async loadMap(): Promise<{ [accessKeyId: string]: Account }> {
if (!(await fs.pathExists(this.cacheFile))) {
return { };
}

return await fs.readJson(this.cacheFile);
}

private async saveMap(map: { [accessKeyId: string]: string }) {
private async saveMap(map: { [accessKeyId: string]: Account }) {
if (!(await fs.pathExists(this.cacheFile))) {
await fs.mkdirs(path.dirname(this.cacheFile));
}
Expand Down
199 changes: 199 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import * as AWS from 'aws-sdk';
import * as child_process from 'child_process';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import * as util from 'util';
import { debug } from '../../logging';
import { SharedIniFile } from "./sdk_ini_file";

/**
* Behaviors to match AWS CLI
*
* See these links:
*
* https://docs.aws.amazon.com/cli/latest/topic/config-vars.html
* https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html
*/
export class AwsCliCompatible {
/**
* Build an AWS CLI-compatible credential chain provider
*
* This is similar to the default credential provider chain created by the SDK
* except:
*
* 1. Accepts profile argument in the constructor (the SDK must have it prepopulated
* in the environment).
* 2. Conditionally checks EC2 credentials, because checking for EC2
* credentials on a non-EC2 machine may lead to long delays (in the best case)
* or an exception (in the worst case).
* 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) {
await forceSdkToReadConfigIfPresent();

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

const sources = [
() => new AWS.EnvironmentCredentials('AWS'),
() => new AWS.EnvironmentCredentials('AMAZON'),
];

if (await fs.pathExists(credentialsFileName())) {
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName() }));
}

if (await fs.pathExists(configFileName())) {
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName() }));
}

if (containerCreds ?? hasEcsCredentials()) {
sources.push(() => new AWS.ECSCredentials());
} else if (ec2creds ?? await hasEc2Credentials()) {
// 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.
sources.push(() => new AWS.EC2MetadataCredentials());
}

return new AWS.CredentialProviderChain(sources);
}

/**
* Return the default region in a CLI-compatible way
*
* Mostly copied from node_loader.js, but with the following differences to make it
* AWS CLI compatible:
*
* 1. Takes a profile name as an argument (instead of forcing it to be taken from $AWS_PROFILE).
* This requires having made a copy of the SDK's `SharedIniFile` (the original
* does not take an argument).
* 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';

// Defaults inside constructor
const toCheck = [
{ filename: credentialsFileName(), profile },
{ isConfig: true, filename: configFileName(), profile },
{ isConfig: true, filename: configFileName(), profile: 'default' },
];

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 options = toCheck.shift()!;
if (await fs.pathExists(options.filename)) {
const configFile = new SharedIniFile(options);
const section = await configFile.getProfile(options.profile);
region = section?.region;
}
}

if (!region) {
const usedProfile = !profile ? '' : ` (profile: "${profile}")`;
region = 'us-east-1'; // This is what the AWS CLI does
debug(`Unable to determine AWS region from environment or AWS configuration${usedProfile}, defaulting to '${region}'`);
}

return region;
}
}

/**
* Return whether it looks like we'll have ECS credentials available
*/
function hasEcsCredentials(): boolean {
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, readIfPossible(file))) {
instance = true;
break;
}
}
}

debug(instance ? 'Looks like EC2 instance.' : 'Does not look like EC2 instance.');
return instance;
}

function homeDir() {
return process.env.HOME || process.env.USERPROFILE
|| (process.env.HOMEPATH ? ((process.env.HOMEDRIVE || 'C:/') + process.env.HOMEPATH) : null) || os.homedir();
}

function credentialsFileName() {
return process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(homeDir(), '.aws', 'credentials');
}

function configFileName() {
return process.env.AWS_CONFIG_FILE || path.join(homeDir(), '.aws', 'config');
}

/**
* Force the JS SDK to honor the ~/.aws/config file (and various settings therein)
*
* For example, ther is just *NO* way to do AssumeRole credentials as long as AWS_SDK_LOAD_CONFIG is not set,
* or read credentials from that file.
*
* The SDK crashes if the variable is set but the file does not exist, so conditionally set it.
*/
async function forceSdkToReadConfigIfPresent() {
if (await fs.pathExists(configFileName())) {
process.env.AWS_SDK_LOAD_CONFIG = '1';
}
}

function matchesRegex(re: RegExp, s: string | undefined) {
return s !== undefined && re.exec(s) !== null;
}

/**
* Read a file if it exists, or return undefined
*
* Not async because it is used in the constructor
*/
function readIfPossible(filename: string): string | undefined {
try {
if (!fs.pathExistsSync(filename)) { return undefined; }
return fs.readFileSync(filename, { encoding: 'utf-8' });
} catch (e) {
debug(e);
return undefined;
}
}
Loading

0 comments on commit d557592

Please sign in to comment.