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

Framework: introducing "Default" construct id #496

Merged
merged 1 commit into from
Aug 3, 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
26 changes: 22 additions & 4 deletions packages/@aws-cdk/cdk/lib/cloudformation/logical-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ export interface IAddressingScheme {
* (i.e. `L1/L2/Pipeline/Pipeline`), they will be de-duplicated to make the
* resulting human portion of the ID more pleasing: `L1L2Pipeline<HASH>`
* instead of `L1L2PipelinePipeline<HASH>`
* - If a component is named "Resource" it will be omitted from the path. This
* allows L2 construct to use this convention to "hide" the wrapped L1 from
* the logical ID.
* - If a component is named "Default" it will be omitted from the path. This
* allows refactoring higher level abstractions around constructs without affecting
* the IDs of already deployed resources.
* - If a component is named "Resource" it will be omitted from the user-visible
* path, but included in the hash. This reduces visual noise in the human readable
* part of the identifier.
*/
export class HashedAddressingScheme implements IAddressingScheme {
public allocateAddress(addressComponents: string[]): string {
addressComponents = addressComponents.filter(x => x !== HIDDEN_ID);

if (addressComponents.length === 0) {
throw new Error('Construct has empty Logical ID');
}
Expand All @@ -65,14 +70,27 @@ export class HashedAddressingScheme implements IAddressingScheme {

const hash = pathHash(addressComponents);
const human = removeDupes(addressComponents)
.filter(x => x !== 'Resource')
.filter(x => x !== HIDDEN_FROM_HUMAN_ID)
.join('')
.slice(0, MAX_HUMAN_LEN);

return human + hash;
}
}

/**
* Resources with this ID are hidden from humans
*
* They do not appear in the human-readable part of the logical ID,
* but they are included in the hash calculation.
*/
const HIDDEN_FROM_HUMAN_ID = 'Resource';

/**
* Resources with this ID are complete hidden from the logical ID calculation.
*/
const HIDDEN_ID = 'Default';

/**
* Class that keeps track of the logical IDs that are assigned to resources
*
Expand Down
27 changes: 27 additions & 0 deletions packages/@aws-cdk/cdk/test/cloudformation/test.logical-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,33 @@ const uniqueTests = {
}
});

test.done();
},

'can transparently wrap constructs using "Default" id'(test: Test) {
// GIVEN
const stack1 = new Stack();
const parent1 = new Construct(stack1, 'Parent');
new Resource(parent1, 'HeyThere', { type: 'AWS::TAAS::Thing' });
const template1 = stack1.toCloudFormation();

// AND
const theId1 = Object.keys(template1.Resources)[0];
test.equal('AWS::TAAS::Thing', template1.Resources[theId1].Type);

// WHEN
const stack2 = new Stack();
const parent2 = new Construct(stack2, 'Parent');
const invisibleWrapper = new Construct(parent2, 'Default');
new Resource(invisibleWrapper, 'HeyThere', { type: 'AWS::TAAS::Thing' });
const template2 = stack1.toCloudFormation();

const theId2 = Object.keys(template2.Resources)[0];
test.equal('AWS::TAAS::Thing', template2.Resources[theId2].Type);

// THEN: same ID, same object
test.equal(theId1, theId2);

test.done();
}
};
Expand Down
29 changes: 17 additions & 12 deletions packages/aws-cdk-docs/src/logical-ids.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,21 @@ Each resource in the construct tree has a unique path that represents its
location within the tree.
Since logical IDs can only use alphanumeric characters and also restricted in
length, the CDK is unable to simply use a delimited path as the logical ID.
Instead, logical IDs are allocated by concatenating a human-friendly rendition
from the path (concatenation, de-duplicate, trim) with an eight-character MD5
hash of the delimited path.
This final component is necessary since |CFN| logical IDs cannot include
the delimiting slash character (/), so simply concatenating the component
values does not work. For example, concatenating the components of the
path */a/b/c* produces **abc**, which is the same as concatenating the components of
Instead, logical IDs are allocated by concatenating a human-friendly rendition
from the path (concatenation, de-duplicate, trim) with an eight-character MD5
hash of the delimited path.
This final component is necessary since |CFN| logical IDs cannot include
the delimiting slash character (/), so simply concatenating the component
values does not work. For example, concatenating the components of the
path */a/b/c* produces **abc**, which is the same as concatenating the components of
the path */ab/c*.

.. code-block:: text

VPCPrivateSubnet2RouteTable0A19E10E
<-----------human---------><-hash->

Low-level CloudFormation resources (from `@aws-cdk/resources`)
Low-level CloudFormation resources (from `@aws-cdk/resources`)
that are direct children of the |stack-class| class use
their name as their logical ID without modification. This makes it easier to
port existing templates into a CDK app.
Expand All @@ -67,8 +67,13 @@ Logical IDs remain unchanged across updates

The |cdk| applies some heuristics to improve the human-friendliness of the prefix:

- If a path component is **Resource**, it is omitted.
This postfix does not normally contribute any additional useful information to the ID.
- If a path component is **Default**, is is hidden completely from the logical ID
computation. You will generally want to use this if you create a new construct
that wraps an existing one. By naming the inner construct **Default**, you
ensure that the logical identifiers of resources in already-deployed copy of
that construct do not change.
- If a path component is **Resource**, it is omitted from the human readable portion.
of the logical ID. This postfix does not normally contribute any additional useful information to the ID.
- If two subsequent names in the path are the same, only one is retained.
- If the prefix exceeds 240 characters, it is trimmed to 240 characters.
This ensures that the total length of the logical ID does not exceed the 255 character
Expand All @@ -92,7 +97,7 @@ logical IDs to certain resources, given either their full path or
// a good practice would be to always put these at the top of your stack initializer.
this.renameLogical('MyTableCD117FA1', 'MyTable');
this.renameLogical('MyQueueAB4432A3', 'MyAwesomeQueue');

new Table(this, 'MyTable');
new Queue(this, 'MyQueue');
}
Expand Down Expand Up @@ -128,5 +133,5 @@ stacks. `cdk diff` will tell you which resources are about to be destroyed:
[-] ☢️ Destroying MyTable (type: AWS::DynamoDB::Table)
[+] 🆕 Creating MyTableCD117FA1 (type: AWS::DynamoDB::Table)

Now, you can add a :py:meth:`aws-cdk.Stack.renameLogical` call before the
Now, you can add a :py:meth:`aws-cdk.Stack.renameLogical` call before the
table is defined to rename **MyTableCD117FA1** to **MyTable**.
2 changes: 1 addition & 1 deletion tools/cdk-build-tools/bin/cdk-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function main() {
testCommand.push(...['nyc', '--clean']);
}
testCommand.push('nodeunit');
testCommand.push(...testFiles);
testCommand.push(...testFiles.map(f => f.path));

await shell(testCommand, timers);
}
Expand Down
37 changes: 30 additions & 7 deletions tools/cdk-build-tools/lib/package-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import fs = require('fs');
import path = require('path');
import util = require('util');

const readdir = util.promisify(fs.readdir);
const stat = util.promisify(fs.stat);

/**
* Return the package JSON for the current package
*/
Expand All @@ -27,9 +30,29 @@ export function isJsii(): boolean {
return currentPackageJson().jsii !== undefined;
}

export async function listFiles(dirName: string, predicate: (x: string) => boolean): Promise<string[]> {
export interface File {
filename: string;
path: string;
}

export async function listFiles(dirName: string, predicate: (x: File) => boolean): Promise<File[]> {
try {
return (await util.promisify(fs.readdir)(dirName)).filter(predicate).map(f => path.join(dirName, f));
const files = (await readdir(dirName)).map(filename => ({ filename, path: path.join(dirName, filename) }));

const ret: File[] = [];
for (const file of files) {
const s = await stat(file.path);
if (s.isDirectory()) {
// Recurse
ret.push(...await listFiles(file.path, predicate));
} else {
if (predicate(file)) {
ret.push(file);
}
}
}

return ret;
} catch (e) {
if (e.code === 'ENOENT') { return []; }
throw e;
Expand All @@ -39,8 +62,8 @@ export async function listFiles(dirName: string, predicate: (x: string) => boole
/**
* Return the unit test files for this package
*/
export async function unitTestFiles(): Promise<string[]> {
return listFiles('test', f => f.startsWith('test.') && f.endsWith('.js'));
export async function unitTestFiles(): Promise<File[]> {
return listFiles('test', f => f.filename.startsWith('test.') && f.filename.endsWith('.js'));
}

/**
Expand All @@ -56,12 +79,12 @@ export async function hasOnlyAutogeneratedTests(): Promise<boolean> {
const packageName = path.basename(process.cwd()).replace(/^aws-/, '');

return (tests.length === 1
&& tests[0] === `test/test.${packageName}.js`
&& fs.readFileSync(tests[0], { encoding: 'utf-8' }).indexOf(AUTOGENERATED_TEST_MARKER) !== -1);
&& tests[0].path === `test/test.${packageName}.js`
&& fs.readFileSync(tests[0].path, { encoding: 'utf-8' }).indexOf(AUTOGENERATED_TEST_MARKER) !== -1);
}

export async function hasIntegTests(): Promise<boolean> {
const files = await listFiles('test', f => f.startsWith('integ.') && f.endsWith('.js'));
const files = await listFiles('test', f => f.filename.startsWith('integ.') && f.filename.endsWith('.js'));
return files.length > 0;
}

Expand Down