diff --git a/packages/@aws-cdk/assert/README.md b/packages/@aws-cdk/assert/README.md index 71c19f3652a51..c81fac74562e9 100644 --- a/packages/@aws-cdk/assert/README.md +++ b/packages/@aws-cdk/assert/README.md @@ -63,6 +63,7 @@ If you only care that a resource of a particular type exists (regardless of its ```ts haveResource(type, subsetOfProperties) +haveResourceLike(type, subsetOfProperties) ``` Example: @@ -76,7 +77,35 @@ expect(stack).to(haveResource('AWS::CertificateManager::Certificate', { })); ``` -`ABSENT` is a magic value to assert that a particular key in an object is *not* set (or set to `undefined`). +The object you give to `haveResource`/`haveResourceLike` like can contain the +following values: + +- **Literal values**: the given property in the resource must match the given value *exactly*. +- `ABSENT`: a magic value to assert that a particular key in an object is *not* set (or set to `undefined`). +- `arrayWith(...)`/`objectLike(...)`/`deepObjectLike(...)`/`exactValue()`: special matchers + for inexact matching. You can use these to match arrays where not all elements have to match, + just a single one, or objects where not all keys have to match. + +The difference between `haveResource` and `haveResourceLike` is the same as +between `objectLike` and `deepObjectLike`: the first allows +additional (unspecified) object keys only at the *first* level, while the +second one allows them in nested objects as well. + +If you want to escape from the "deep lenient matching" behavior, you can use +`exactValue()`. + +Slightly more complex example with array matchers: + +```ts +expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(objectLike({ + Action: ['s3:GetObject'], + Resource: ['arn:my:arn'], + }}) + } +})); +``` ### Check number of resources diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index cf7b9c6d15da1..3676f06352068 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -40,21 +40,24 @@ export function haveResourceLike( return haveResource(resourceType, properties, comparison, true); } -type PropertyPredicate = (props: any, inspection: InspectionFailure) => boolean; +export type PropertyMatcher = (props: any, inspection: InspectionFailure) => boolean; export class HaveResourceAssertion extends JestFriendlyAssertion { private readonly inspected: InspectionFailure[] = []; private readonly part: ResourcePart; - private readonly predicate: PropertyPredicate; + private readonly matcher: any; constructor( private readonly resourceType: string, - private readonly properties?: any, + properties?: any, part?: ResourcePart, allowValueExtension: boolean = false) { super(); - this.predicate = typeof properties === 'function' ? properties : makeSuperObjectPredicate(properties, allowValueExtension); + this.matcher = isCallable(properties) ? properties : + properties === undefined ? anything() : + allowValueExtension ? deepObjectLike(properties) : + objectLike(properties); this.part = part !== undefined ? part : ResourcePart.Properties; } @@ -68,7 +71,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion // to maintain backwards compatibility with old predicate API. const inspection = { resource, failureReason: 'Object did not match predicate' }; - if (this.predicate(propsToCheck, inspection)) { + if (match(propsToCheck, this.matcher, inspection)) { return true; } @@ -99,7 +102,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion public get description(): string { // tslint:disable-next-line:max-line-length - return `resource '${this.resourceType}' with properties ${JSON.stringify(this.properties, undefined, 2)}`; + return `resource '${this.resourceType}' with ${JSON.stringify(this.matcher, undefined, 2)}`; } } @@ -108,111 +111,275 @@ function indent(n: number, s: string) { return prefix + s.replace(/\n/g, '\n' + prefix); } -/** - * Make a predicate that checks property superset - */ -function makeSuperObjectPredicate(obj: any, allowValueExtension: boolean) { - return (resourceProps: any, inspection: InspectionFailure) => { - const errors: string[] = []; - const ret = isSuperObject(resourceProps, obj, errors, allowValueExtension); - inspection.failureReason = errors.join(','); - return ret; - }; -} - export interface InspectionFailure { resource: any; failureReason: string; } /** - * Return whether `superObj` is a super-object of `obj`. + * Match a given literal value against a matcher * - * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. + * If the matcher is a callable, use that to evaluate the value. Otherwise, the values + * must be literally the same. */ -export function isSuperObject(superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false): boolean { +function match(value: any, matcher: any, inspection: InspectionFailure) { + if (isCallable(matcher)) { + // Custom matcher (this mostly looks very weird because our `InspectionFailure` signature is weird) + const innerInspection: InspectionFailure = { ...inspection, failureReason: '' }; + const result = matcher(value, innerInspection); + if (typeof result !== 'boolean') { + return failMatcher(inspection, `Predicate returned non-boolean return value: ${result}`); + } + if (!result && !innerInspection.failureReason) { + // Custom matcher neglected to return an error + return failMatcher(inspection, 'Predicate returned false'); + } + // Propagate inner error in case of failure + if (!result) { inspection.failureReason = innerInspection.failureReason; } + return result; + } + + return matchLiteral(value, matcher, inspection); +} + +/** + * Match a literal value at the top level. + * + * When recursing into arrays or objects, the nested values can be either matchers + * or literals. + */ +function matchLiteral(value: any, pattern: any, inspection: InspectionFailure) { if (pattern == null) { return true; } - if (Array.isArray(superObj) !== Array.isArray(pattern)) { - errors.push('Array type mismatch'); - return false; + + const errors = new Array(); + + if (Array.isArray(value) !== Array.isArray(pattern)) { + return failMatcher(inspection, 'Array type mismatch'); } - if (Array.isArray(superObj)) { - if (pattern.length !== superObj.length) { - errors.push('Array length mismatch'); - return false; + if (Array.isArray(value)) { + if (pattern.length !== value.length) { + return failMatcher(inspection, 'Array length mismatch'); } - // Do isSuperObject comparison for individual objects + // Recurse comparison for individual objects for (let i = 0; i < pattern.length; i++) { - if (!isSuperObject(superObj[i], pattern[i], [], allowValueExtension)) { + if (!match(value[i], pattern[i], { ...inspection })) { errors.push(`Array element ${i} mismatch`); } } - return errors.length === 0; + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; } - if ((typeof superObj === 'object') !== (typeof pattern === 'object')) { - errors.push('Object type mismatch'); - return false; + if ((typeof value === 'object') !== (typeof pattern === 'object')) { + return failMatcher(inspection, 'Object type mismatch'); } if (typeof pattern === 'object') { + // Check that all fields in the pattern have the right value + const innerInspection = { ...inspection, failureReason: '' }; + const matcher = objectLike(pattern)(value, innerInspection); + if (!matcher) { + inspection.failureReason = innerInspection.failureReason; + return false; + } + + // Check no fields uncovered + const realFields = new Set(Object.keys(value)); + for (const key of Object.keys(pattern)) { realFields.delete(key); } + if (realFields.size > 0) { + return failMatcher(inspection, `Unexpected keys present in object: ${Array.from(realFields).join(', ')}`); + } + return true; + } + + if (value !== pattern) { + return failMatcher(inspection, 'Different values'); + } + + return true; +} + +/** + * Helper function to make matcher failure reporting a little easier + * + * Our protocol is weird (change a string on a passed-in object and return 'false'), + * but I don't want to change that right now. + */ +function failMatcher(inspection: InspectionFailure, error: string): boolean { + inspection.failureReason = error; + return false; +} + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Only does lenient matching one level deep, at the next level all objects must declare the + * exact expected keys again. + */ +export function objectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, false); +} + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Switches to "deep" lenient matching. Nested objects also only need to contain declared keys. + */ +export function deepObjectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, true); +} + +export function _objectContaining(pattern: A, deep: boolean): PropertyMatcher { + const ret = (value: any, inspection: InspectionFailure): boolean => { + if (typeof value !== 'object' || !value) { + return failMatcher(inspection, `Expect an object but got '${typeof value}'`); + } + + const errors = new Array(); + for (const [patternKey, patternValue] of Object.entries(pattern)) { if (patternValue === ABSENT) { - if (superObj[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } + if (value[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } continue; } - if (!(patternKey in superObj)) { + if (!(patternKey in value)) { errors.push(`Field ${patternKey} missing`); continue; } - const innerErrors = new Array(); - const valueMatches = allowValueExtension - ? isSuperObject(superObj[patternKey], patternValue, innerErrors, allowValueExtension) - : isStrictlyEqual(superObj[patternKey], patternValue, innerErrors); + // If we are doing DEEP objectLike, translate object literals in the pattern into + // more `deepObjectLike` matchers, even if they occur in lists. + const matchValue = deep ? deepMatcherFromObjectLiteral(patternValue) : patternValue; + + const innerInspection = { ...inspection, failureReason: '' }; + const valueMatches = match(value[patternKey], matchValue, innerInspection); if (!valueMatches) { - errors.push(`Field ${patternKey} mismatch: ${innerErrors.join(', ')}`); + errors.push(`Field ${patternKey} mismatch: ${innerInspection.failureReason}`); } } - return errors.length === 0; - } - if (superObj !== pattern) { - errors.push('Different values'); - } - return errors.length === 0; + /** + * Transform nested object literals into more deep object matchers, if applicable + * + * Object literals in lists are also transformed. + */ + function deepMatcherFromObjectLiteral(nestedPattern: any): any { + if (isObject(nestedPattern)) { + return deepObjectLike(nestedPattern); + } + if (Array.isArray(nestedPattern)) { + return nestedPattern.map(deepMatcherFromObjectLiteral); + } + return nestedPattern; + } + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; + }; + + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ [deep ? '$deepObjectLike' : '$objectLike']: pattern }); + return ret; } -function isStrictlyEqual(left: any, pattern: any, errors: string[]): boolean { - if (left === pattern) { return true; } - if (typeof left !== typeof pattern) { - errors.push(`${typeof left} !== ${typeof pattern}`); - return false; - } +/** + * Match exactly the given value + * + * This is the default, you only need this to escape from the deep lenient matching + * of `deepObjectLike`. + */ +export function exactValue(expected: any): PropertyMatcher { + const ret = (value: any, inspection: InspectionFailure): boolean => { + return matchLiteral(value, expected, inspection); + }; - if (typeof left === 'object' && typeof pattern === 'object') { - if (Array.isArray(left) !== Array.isArray(pattern)) { return false; } - const allKeys = new Set([...Object.keys(left), ...Object.keys(pattern)]); - for (const key of allKeys) { - if (pattern[key] === ABSENT) { - if (left[key] !== undefined) { - errors.push(`Field ${key} present, but shouldn't be`); - return false; - } - return true; + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ $exactValue: expected }); + return ret; +} + +/** + * A matcher for a list that contains all of the given elements in any order + */ +export function arrayWith(...elements: any[]): PropertyMatcher { + if (elements.length === 0) { return anything(); } + + const ret = (value: any, inspection: InspectionFailure): boolean => { + if (!Array.isArray(value)) { + return failMatcher(inspection, `Expect an object but got '${typeof value}'`); + } + + for (const element of elements) { + const failure = longestFailure(value, element); + if (failure) { + return failMatcher(inspection, `Array did not contain expected element, closest match at index ${failure[0]}: ${failure[1]}`); } + } + + return true; + + /** + * Return 'null' if the matcher matches anywhere in the array, otherwise the longest error and its index + */ + function longestFailure(array: any[], matcher: any): [number, string] | null { + let fail: [number, string] | null = null; + for (let i = 0; i < array.length; i++) { + const innerInspection = { ...inspection, failureReason: '' }; + if (match(array[i], matcher, innerInspection)) { + return null; + } - const innerErrors = new Array(); - if (!isStrictlyEqual(left[key], pattern[key], innerErrors)) { - errors.push(`${Array.isArray(left) ? 'element ' : ''}${key}: ${innerErrors.join(', ')}`); - return false; + if (fail === null || innerInspection.failureReason.length > fail[1].length) { + fail = [i, innerInspection.failureReason]; + } } + return fail; } + }; + + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ $arrayContaining: elements.length === 1 ? elements[0] : elements }); + return ret; +} + +/** + * Matches anything + */ +function anything() { + const ret = () => { return true; - } + }; + ret.toJSON = () => ({ $anything: true }); + return ret; +} - errors.push(`${left} !== ${pattern}`); - return false; +/** + * Return whether `superObj` is a super-object of `obj`. + * + * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. + * + * At any point in the object, a value may be replaced with a function which will be used to check that particular field. + * The type of a matcher function is expected to be of type PropertyMatcher. + * + * @deprecated - Use `objectLike` or a literal object instead. + */ +export function isSuperObject(superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false): boolean { + const matcher = allowValueExtension ? deepObjectLike(pattern) : objectLike(pattern); + + const inspection: InspectionFailure = { resource: superObj, failureReason: '' }; + const ret = match(superObj, matcher, inspection); + if (!ret) { + errors.push(inspection.failureReason); + } + return ret; } /** @@ -231,3 +398,18 @@ export enum ResourcePart { */ CompleteDefinition } + +/** + * Whether a value is a callable + */ +function isCallable(x: any): x is ((...args: any[]) => any) { + return x && {}.toString.call(x) === '[object Function]'; +} + +/** + * Whether a value is an object + */ +function isObject(x: any): x is object { + // Because `typeof null === 'object'`. + return x && typeof x === 'object'; +} \ No newline at end of file diff --git a/packages/@aws-cdk/assert/test/have-resource.test.ts b/packages/@aws-cdk/assert/test/have-resource.test.ts index b523fd2a8bcfc..69ab649433350 100644 --- a/packages/@aws-cdk/assert/test/have-resource.test.ts +++ b/packages/@aws-cdk/assert/test/have-resource.test.ts @@ -2,7 +2,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import { writeFileSync } from 'fs'; import { join } from 'path'; -import { ABSENT, expect as cdkExpect, haveResource } from '../lib/index'; +import { ABSENT, arrayWith, exactValue, expect as cdkExpect, haveResource, haveResourceLike } from '../lib/index'; test('support resource with no properties', () => { const synthStack = mkStack({ @@ -138,6 +138,106 @@ describe('property absence', () => { }).toThrowError(/Prop/); }); + test('can use matcher to test for list element', () => { + const synthStack = mkSomeResource({ + List: [ + { Prop: 'distraction' }, + { Prop: 'goal' }, + ], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith({ Prop: 'goal' }), + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith({ Prop: 'missme' }), + })); + }).toThrowError(/Array did not contain expected element/); + }); + + test('arrayContaining must match all elements in any order', () => { + const synthStack = mkSomeResource({ + List: ['a', 'b'], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith('b', 'a'), + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith('a', 'c'), + })); + }).toThrowError(/Array did not contain expected element/); + }); + + test('exactValue escapes from deep fuzzy matching', () => { + const synthStack = mkSomeResource({ + Deep: { + PropA: 'A', + PropB: 'B', + }, + }); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + Deep: { + PropA: 'A', + }, + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + Deep: exactValue({ + PropA: 'A', + }), + })); + }).toThrowError(/Unexpected keys present in object/); + }); + + /** + * Backwards compatibility test + * + * If we had designed this with a matcher library from the start, we probably wouldn't + * have had this behavior, but here we are. + * + * Historically, when we do `haveResourceLike` (which maps to `objectContainingDeep`) with + * a pattern containing lists of objects, the objects inside the list are also matched + * as 'containing' keys (instead of having to completely 'match' the pattern objects). + * + * People will have written assertions depending on this behavior, so we have to maintain + * it. + */ + test('objectContainingDeep has deep effect through lists', () => { + const synthStack = mkSomeResource({ + List: [ + { + PropA: 'A', + PropB: 'B', + }, + { + PropA: 'A', + PropB: 'B', + }, + ], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + List: [ + { PropA: 'A' }, + { PropB: 'B' }, + ], + })); + }).not.toThrowError(); + }); }); function mkStack(template: any): cxapi.CloudFormationStackArtifact { diff --git a/packages/@aws-cdk/assets/lib/api.ts b/packages/@aws-cdk/assets/lib/api.ts index 75966e57d5af8..a575c92c293a9 100644 --- a/packages/@aws-cdk/assets/lib/api.ts +++ b/packages/@aws-cdk/assets/lib/api.ts @@ -1,5 +1,7 @@ /** * Common interface for all assets. + * + * @deprecated use `core.IAsset` */ export interface IAsset { /** diff --git a/packages/@aws-cdk/assets/lib/index.ts b/packages/@aws-cdk/assets/lib/index.ts index c651e06cc2ac1..e2a67003867bd 100644 --- a/packages/@aws-cdk/assets/lib/index.ts +++ b/packages/@aws-cdk/assets/lib/index.ts @@ -1,4 +1,4 @@ export * from './api'; export * from './fs/follow-mode'; export * from './fs/options'; -export * from './staging'; \ No newline at end of file +export * from './staging'; diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/package.json b/packages/@aws-cdk/aws-autoscaling-hooktargets/package.json index 6a8d4669fc8fc..fc353a027cd84 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/package.json +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/package.json b/packages/@aws-cdk/aws-cloudwatch-actions/package.json index 8f8d1edd98766..b0fda2aa4968d 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/package.json +++ b/packages/@aws-cdk/aws-cloudwatch-actions/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-docdb/package.json b/packages/@aws-cdk/aws-docdb/package.json index cde479290182a..3fa00723a3c94 100644 --- a/packages/@aws-cdk/aws-docdb/package.json +++ b/packages/@aws-cdk/aws-docdb/package.json @@ -51,7 +51,7 @@ "cloudformation": "AWS::DocDB", "jest": true }, -"keywords": [ + "keywords": [ "aws", "cdk", "constructs", diff --git a/packages/@aws-cdk/aws-dynamodb-global/package.json b/packages/@aws-cdk/aws-dynamodb-global/package.json index c280e52d7ffc4..e214cbbbb210c 100644 --- a/packages/@aws-cdk/aws-dynamodb-global/package.json +++ b/packages/@aws-cdk/aws-dynamodb-global/package.json @@ -57,7 +57,6 @@ "@types/nodeunit": "^0.0.31", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", - "cfn2ts": "0.0.0", "nodeunit": "^0.11.3", "pkglint": "0.0.0" }, @@ -77,7 +76,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-ecs-patterns/package.json b/packages/@aws-cdk/aws-ecs-patterns/package.json index 451153745909f..16bcc51f7e25e 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/package.json +++ b/packages/@aws-cdk/aws-ecs-patterns/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-eks/lib/fargate-profile.ts b/packages/@aws-cdk/aws-eks/lib/fargate-profile.ts index 5a96731a7b17d..b9b45bb1d8ebe 100644 --- a/packages/@aws-cdk/aws-eks/lib/fargate-profile.ts +++ b/packages/@aws-cdk/aws-eks/lib/fargate-profile.ts @@ -132,6 +132,12 @@ export class FargateProfile extends Construct implements ITaggable { constructor(scope: Construct, id: string, props: FargateProfileProps) { super(scope, id); + // currently the custom resource requires a role to assume when interacting with the cluster + // and we only have this role when kubectl is enabled. + if (!props.cluster.kubectlEnabled) { + throw new Error('adding Faregate Profiles to clusters without kubectl enabled is currently unsupported'); + } + const provider = ClusterResourceProvider.getOrCreate(this); const role = props.podExecutionRole ?? new iam.Role(this, 'PodExecutionRole', { @@ -173,5 +179,16 @@ export class FargateProfile extends Construct implements ITaggable { this.fargateProfileArn = resource.getAttString('fargateProfileArn'); this.fargateProfileName = resource.ref; + + // map the fargate pod execution role to the relevant groups in rbac + // see https://github.com/aws/aws-cdk/issues/7981 + props.cluster.awsAuth.addRoleMapping(role, { + username: 'system:node:{{SessionName}}', + groups: [ + 'system:bootstrappers', + 'system:nodes', + 'system:node-proxier', + ], + }); } } diff --git a/packages/@aws-cdk/aws-eks/test/integ.eks-cluster.expected.json b/packages/@aws-cdk/aws-eks/test/integ.eks-cluster.expected.json index 164377d944797..7a24571d092ff 100644 --- a/packages/@aws-cdk/aws-eks/test/integ.eks-cluster.expected.json +++ b/packages/@aws-cdk/aws-eks/test/integ.eks-cluster.expected.json @@ -925,6 +925,13 @@ ] }, "\\\",\\\"groups\\\":[\\\"system:masters\\\"]},{\\\"rolearn\\\":\\\"", + { + "Fn::GetAtt": [ + "ClusterfargateprofiledefaultPodExecutionRole09952CFF", + "Arn" + ] + }, + "\\\",\\\"username\\\":\\\"system:node:{{SessionName}}\\\",\\\"groups\\\":[\\\"system:bootstrappers\\\",\\\"system:nodes\\\",\\\"system:node-proxier\\\"]},{\\\"rolearn\\\":\\\"", { "Fn::GetAtt": [ "ClusterNodesInstanceRoleC3C01328", diff --git a/packages/@aws-cdk/aws-eks/test/test.fargate.ts b/packages/@aws-cdk/aws-eks/test/test.fargate.ts index d571576a4d0ab..7fe71200c245a 100644 --- a/packages/@aws-cdk/aws-eks/test/test.fargate.ts +++ b/packages/@aws-cdk/aws-eks/test/test.fargate.ts @@ -251,4 +251,46 @@ export = { })); test.done(); }, + + 'fargate role is added to RBAC'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new eks.FargateCluster(stack, 'FargateCluster'); + + // THEN + expect(stack).to(haveResource('Custom::AWSCDK-EKS-KubernetesResource', { + Manifest: { + 'Fn::Join': [ + '', + [ + '[{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"aws-auth","namespace":"kube-system"},"data":{"mapRoles":"[{\\"rolearn\\":\\"', + { + 'Fn::GetAtt': [ + 'FargateClusterfargateprofiledefaultPodExecutionRole66F2610E', + 'Arn', + ], + }, + '\\",\\"username\\":\\"system:node:{{SessionName}}\\",\\"groups\\":[\\"system:bootstrappers\\",\\"system:nodes\\",\\"system:node-proxier\\"]}]","mapUsers":"[]","mapAccounts":"[]"}}]', + ], + ], + }, + })); + test.done(); + }, + + 'cannot be added to a cluster without kubectl enabled'(test: Test) { + // GIVEN + const stack = new Stack(); + const cluster = new eks.Cluster(stack, 'MyCluster', { kubectlEnabled: false }); + + // WHEN + test.throws(() => new eks.FargateProfile(stack, 'MyFargateProfile', { + cluster, + selectors: [ { namespace: 'default' } ], + }), /unsupported/); + + test.done(); + }, }; diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2-actions/package.json b/packages/@aws-cdk/aws-elasticloadbalancingv2-actions/package.json index c1a5a92fb1053..ba866cf3a4dee 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2-actions/package.json +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2-actions/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json b/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json index 768f9e7eebc7f..fdef0856ac238 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/nlb/network-load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/nlb/network-load-balancer.ts index 02c7855534d5b..cf7ccbf04b1ed 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/nlb/network-load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/nlb/network-load-balancer.ts @@ -1,5 +1,7 @@ import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; import * as ec2 from '@aws-cdk/aws-ec2'; +import { PolicyStatement, ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IBucket } from '@aws-cdk/aws-s3'; import { Construct, Resource } from '@aws-cdk/core'; import { BaseLoadBalancer, BaseLoadBalancerProps, ILoadBalancerV2 } from '../shared/base-load-balancer'; import { BaseNetworkListenerProps, NetworkListener } from './network-listener'; @@ -101,6 +103,41 @@ export class NetworkLoadBalancer extends BaseLoadBalancer implements INetworkLoa }); } + /** + * Enable access logging for this load balancer. + * + * A region must be specified on the stack containing the load balancer; you cannot enable logging on + * environment-agnostic stacks. See https://docs.aws.amazon.com/cdk/latest/guide/environments.html + * + * This is extending the BaseLoadBalancer.logAccessLogs method to match the bucket permissions described + * at https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-access-logs.html#access-logging-bucket-requirements + */ + public logAccessLogs(bucket: IBucket, prefix?: string) { + super.logAccessLogs(bucket, prefix); + + const logsDeliveryServicePrincipal = new ServicePrincipal('delivery.logs.amazonaws.com'); + + bucket.addToResourcePolicy( + new PolicyStatement({ + actions: ['s3:PutObject'], + principals: [logsDeliveryServicePrincipal], + resources: [ + bucket.arnForObjects(`${prefix ? prefix + '/' : ''}AWSLogs/${this.stack.account}/*`), + ], + conditions: { + StringEquals: { 's3:x-amz-acl': 'bucket-owner-full-control' }, + }, + }), + ); + bucket.addToResourcePolicy( + new PolicyStatement({ + actions: ['s3:GetBucketAcl'], + principals: [logsDeliveryServicePrincipal], + resources: [bucket.bucketArn], + }), + ); + } + /** * Return the given named metric for this Network Load Balancer * diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/nlb/test.load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/nlb/test.load-balancer.ts index 3fdaf593d0be4..4ee545b0ccfdc 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/nlb/test.load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/nlb/test.load-balancer.ts @@ -115,6 +115,24 @@ export = { { Ref: 'AWS::AccountId' }, '/*']], }, }, + { + Action: 's3:PutObject', + Condition: { StringEquals: { 's3:x-amz-acl': 'bucket-owner-full-control' }}, + Effect: 'Allow', + Principal: { Service: 'delivery.logs.amazonaws.com' }, + Resource: { + 'Fn::Join': ['', [{ 'Fn::GetAtt': ['AccessLoggingBucketA6D88F29', 'Arn'] }, '/AWSLogs/', + { Ref: 'AWS::AccountId' }, '/*']], + }, + }, + { + Action: 's3:GetBucketAcl', + Effect: 'Allow', + Principal: { Service: 'delivery.logs.amazonaws.com' }, + Resource: { + 'Fn::GetAtt': ['AccessLoggingBucketA6D88F29', 'Arn'], + }, + }, ], }, })); @@ -170,6 +188,24 @@ export = { { Ref: 'AWS::AccountId' }, '/*']], }, }, + { + Action: 's3:PutObject', + Condition: { StringEquals: { 's3:x-amz-acl': 'bucket-owner-full-control' }}, + Effect: 'Allow', + Principal: { Service: 'delivery.logs.amazonaws.com' }, + Resource: { + 'Fn::Join': ['', [{ 'Fn::GetAtt': ['AccessLoggingBucketA6D88F29', 'Arn'] }, '/prefix-of-access-logs/AWSLogs/', + { Ref: 'AWS::AccountId' }, '/*']], + }, + }, + { + Action: 's3:GetBucketAcl', + Effect: 'Allow', + Principal: { Service: 'delivery.logs.amazonaws.com' }, + Resource: { + 'Fn::GetAtt': ['AccessLoggingBucketA6D88F29', 'Arn'], + }, + }, ], }, })); diff --git a/packages/@aws-cdk/aws-events-targets/package.json b/packages/@aws-cdk/aws-events-targets/package.json index b9f9efad57c95..64b7060517815 100644 --- a/packages/@aws-cdk/aws-events-targets/package.json +++ b/packages/@aws-cdk/aws-events-targets/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-lambda-destinations/package.json b/packages/@aws-cdk/aws-lambda-destinations/package.json index 92805e6004bd9..d02ddde7aa635 100644 --- a/packages/@aws-cdk/aws-lambda-destinations/package.json +++ b/packages/@aws-cdk/aws-lambda-destinations/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-lambda-nodejs/package.json b/packages/@aws-cdk/aws-lambda-nodejs/package.json index b415c57d92d9e..31995f757c849 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/package.json +++ b/packages/@aws-cdk/aws-lambda-nodejs/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-lambda/README.md b/packages/@aws-cdk/aws-lambda/README.md index 92ed4dc61392b..01b211d16e142 100644 --- a/packages/@aws-cdk/aws-lambda/README.md +++ b/packages/@aws-cdk/aws-lambda/README.md @@ -32,7 +32,8 @@ runtime code. * `lambda.Code.fromInline(code)` - inline the handle code as a string. This is limited to supported runtimes and the code cannot exceed 4KiB. * `lambda.Code.fromAsset(path)` - specify a directory or a .zip file in the local - filesystem which will be zipped and uploaded to S3 before deployment. + filesystem which will be zipped and uploaded to S3 before deployment. See also + [bundling asset code](#Bundling-Asset-Code). The following example shows how to define a Python function and deploy the code from the local directory `my-lambda-handler` to it: @@ -62,7 +63,7 @@ const fn = new lambda.Function(this, 'MyFunction', { runtime: lambda.Runtime.NODEJS_10_X, handler: 'index.handler', code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-handler')), - + fn.role // the Role ``` @@ -287,6 +288,53 @@ number of times and with different properties. Using `SingletonFunction` here wi For example, the `LogRetention` construct requires only one single lambda function for all different log groups whose retention it seeks to manage. +### Bundling Asset Code +When using `lambda.Code.fromAsset(path)` it is possible to bundle the code by running a +command in a Docker container. The asset path will be mounted at `/asset-input`. The +Docker container is responsible for putting content at `/asset-output`. The content at +`/asset-output` will be zipped and used as Lambda code. + +Example with Python: +```ts +new lambda.Function(this, 'Function', { + code: lambda.Code.fromAsset(path.join(__dirname, 'my-python-handler'), { + bundling: { + image: lambda.Runtime.PYTHON_3_6.bundlingDockerImage, + command: [ + 'bash', '-c', ` + pip install -r requirements.txt -t /asset-output && + rsync -r . /asset-output + `, + ], + }, + }), + runtime: lambda.Runtime.PYTHON_3_6, + handler: 'index.handler', +}); +``` +Runtimes expose a `bundlingDockerImage` property that points to the [lambci/lambda](https://hub.docker.com/r/lambci/lambda/) build image. + +Use `cdk.BundlingDockerImage.fromRegistry(image)` to use an existing image or +`cdk.BundlingDockerImage.fromAsset(path)` to build a specific image: + +```ts +import * as cdk from '@aws-cdk/core'; + +new lambda.Function(this, 'Function', { + code: lambda.Code.fromAsset('/path/to/handler', { + bundling: { + image: cdk.BundlingDockerImage.fromAsset('/path/to/dir/with/DockerFile', { + buildArgs: { + ARG1: 'value1', + }, + }), + command: ['my', 'cool', 'command'], + }, + }), + // ... +}); +``` + ### Language-specific APIs Language-specific higher level constructs are provided in separate modules: diff --git a/packages/@aws-cdk/aws-lambda/lib/runtime.ts b/packages/@aws-cdk/aws-lambda/lib/runtime.ts index ffa111ca4509b..25f36b6a4f53f 100644 --- a/packages/@aws-cdk/aws-lambda/lib/runtime.ts +++ b/packages/@aws-cdk/aws-lambda/lib/runtime.ts @@ -1,3 +1,5 @@ +import { BundlingDockerImage } from '@aws-cdk/core'; + export interface LambdaRuntimeProps { /** * Whether the ``ZipFile`` (aka inline code) property can be used with this runtime. @@ -154,10 +156,19 @@ export class Runtime { */ public readonly family?: RuntimeFamily; + /** + * The bundling Docker image for this runtime. + * Points to the lambci/lambda build image for this runtime. + * + * @see https://hub.docker.com/r/lambci/lambda/ + */ + public readonly bundlingDockerImage: BundlingDockerImage; + constructor(name: string, family?: RuntimeFamily, props: LambdaRuntimeProps = { }) { this.name = name; this.supportsInlineCode = !!props.supportsInlineCode; this.family = family; + this.bundlingDockerImage = BundlingDockerImage.fromRegistry(`lambci/lambda:build-${name}`); Runtime.ALL.push(this); } diff --git a/packages/@aws-cdk/aws-lambda/test/integ.bundling.expected.json b/packages/@aws-cdk/aws-lambda/test/integ.bundling.expected.json new file mode 100644 index 0000000000000..aa5a63c7a3c3d --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.bundling.expected.json @@ -0,0 +1,113 @@ +{ + "Resources": { + "FunctionServiceRole675BB04A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Function76856677": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdS3Bucket6365D8AA" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdS3VersionKey14A1DBA7" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdS3VersionKey14A1DBA7" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionServiceRole675BB04A", + "Arn" + ] + }, + "Runtime": "python3.6" + }, + "DependsOn": [ + "FunctionServiceRole675BB04A" + ] + } + }, + "Parameters": { + "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdS3Bucket6365D8AA": { + "Type": "String", + "Description": "S3 bucket for asset \"0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cd\"" + }, + "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdS3VersionKey14A1DBA7": { + "Type": "String", + "Description": "S3 key for asset version \"0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cd\"" + }, + "AssetParameters0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cdArtifactHashEEC2ED67": { + "Type": "String", + "Description": "Artifact hash for asset \"0ccf37fa0b92d4598d010192eb994040c2e22cc6b12270736d323437817112cd\"" + } + }, + "Outputs": { + "FunctionArn": { + "Value": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts b/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts new file mode 100644 index 0000000000000..6c1715bd05747 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts @@ -0,0 +1,42 @@ +import { App, CfnOutput, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as path from 'path'; +import * as lambda from '../lib'; + +/** + * Stack verification steps: + * * aws cloudformation describe-stacks --stack-name cdk-integ-lambda-bundling --query Stacks[0].Outputs[0].OutputValue + * * aws lambda invoke --function-name response.json + * * cat response.json + * The last command should show '200' + */ +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const assetPath = path.join(__dirname, 'python-lambda-handler'); + const fn = new lambda.Function(this, 'Function', { + code: lambda.Code.fromAsset(assetPath, { + bundling: { + image: lambda.Runtime.PYTHON_3_6.bundlingDockerImage, + command: [ + 'bash', '-c', [ + 'rsync -r . /asset-output', + 'cd /asset-output', + 'pip install -r requirements.txt -t .', + ].join(' && '), + ], + }, + }), + runtime: lambda.Runtime.PYTHON_3_6, + handler: 'index.handler', + }); + + new CfnOutput(this, 'FunctionArn', { + value: fn.functionArn, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-lambda-bundling'); +app.synth(); diff --git a/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/index.py b/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/index.py new file mode 100644 index 0000000000000..175a36616590a --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/index.py @@ -0,0 +1,8 @@ +import requests + +def handler(event, context): + r = requests.get('https://aws.amazon.com') + + print(r.status_code) + + return r.status_code diff --git a/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/requirements.txt b/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/requirements.txt new file mode 100644 index 0000000000000..b4500579db515 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/python-lambda-handler/requirements.txt @@ -0,0 +1 @@ +requests==2.23.0 diff --git a/packages/@aws-cdk/aws-logs-destinations/package.json b/packages/@aws-cdk/aws-logs-destinations/package.json index c7e151e165b6c..bfa6f4a73f371 100644 --- a/packages/@aws-cdk/aws-logs-destinations/package.json +++ b/packages/@aws-cdk/aws-logs-destinations/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-route53-patterns/package.json b/packages/@aws-cdk/aws-route53-patterns/package.json index 09de36a416910..56855cc2c70b0 100644 --- a/packages/@aws-cdk/aws-route53-patterns/package.json +++ b/packages/@aws-cdk/aws-route53-patterns/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-route53-targets/package.json b/packages/@aws-cdk/aws-route53-targets/package.json index 80d2e61663189..f7ab4f96b29b9 100644 --- a/packages/@aws-cdk/aws-route53-targets/package.json +++ b/packages/@aws-cdk/aws-route53-targets/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-s3-assets/README.md b/packages/@aws-cdk/aws-s3-assets/README.md index 86490d0421025..07d3a88bb0208 100644 --- a/packages/@aws-cdk/aws-s3-assets/README.md +++ b/packages/@aws-cdk/aws-s3-assets/README.md @@ -50,6 +50,9 @@ The following examples grants an IAM group read permissions on an asset: [Example of granting read access to an asset](./test/integ.assets.permissions.lit.ts) +The following example uses custom asset bundling to convert a markdown file to html: +[Example of using asset bundling](./test/integ.assets.bundling.lit.ts) + ## How does it work? When an asset is defined in a construct, a construct metadata entry diff --git a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts index 5c4b6e6cb3eb9..5c3f0a514f07e 100644 --- a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts +++ b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts @@ -5,11 +5,11 @@ import * as cdk from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs'; import * as path from 'path'; +import { toSymlinkFollow } from './compat'; const ARCHIVE_EXTENSIONS = [ '.zip', '.jar' ]; -export interface AssetOptions extends assets.CopyOptions { - +export interface AssetOptions extends assets.CopyOptions, cdk.AssetOptions { /** * A list of principals that should be able to read this asset from S3. * You can use `asset.grantRead(principal)` to grant read permissions later. @@ -30,7 +30,7 @@ export interface AssetOptions extends assets.CopyOptions { * @default - automatically calculate source hash based on the contents * of the source file or directory. * - * @experimental + * @deprecated see `assetHash` and `assetHashType` */ readonly sourceHash?: string; } @@ -50,7 +50,7 @@ export interface AssetProps extends AssetOptions { * An asset represents a local file or directory, which is automatically uploaded to S3 * and then can be referenced within a CDK application. */ -export class Asset extends cdk.Construct implements assets.IAsset { +export class Asset extends cdk.Construct implements cdk.IAsset { /** * Attribute that represents the name of the bucket this asset exists in. */ @@ -98,18 +98,28 @@ export class Asset extends cdk.Construct implements assets.IAsset { */ public readonly isZipArchive: boolean; + /** + * A cryptographic hash of the asset. + * + * @deprecated see `assetHash` + */ public readonly sourceHash: string; + public readonly assetHash: string; + constructor(scope: cdk.Construct, id: string, props: AssetProps) { super(scope, id); // stage the asset source (conditionally). - const staging = new assets.Staging(this, 'Stage', { + const staging = new cdk.AssetStaging(this, 'Stage', { ...props, sourcePath: path.resolve(props.path), + follow: toSymlinkFollow(props.follow), + assetHash: props.assetHash ?? props.sourceHash, }); - this.sourceHash = props.sourceHash || staging.sourceHash; + this.assetHash = staging.assetHash; + this.sourceHash = this.assetHash; this.assetPath = staging.stagedPath; @@ -136,7 +146,7 @@ export class Asset extends cdk.Construct implements assets.IAsset { this.bucket = s3.Bucket.fromBucketName(this, 'AssetBucket', this.s3BucketName); - for (const reader of (props.readers || [])) { + for (const reader of (props.readers ?? [])) { this.grantRead(reader); } } diff --git a/packages/@aws-cdk/aws-s3-assets/lib/compat.ts b/packages/@aws-cdk/aws-s3-assets/lib/compat.ts new file mode 100644 index 0000000000000..af080a15615a2 --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/lib/compat.ts @@ -0,0 +1,17 @@ +import { FollowMode } from '@aws-cdk/assets'; +import { SymlinkFollowMode } from '@aws-cdk/core'; + +export function toSymlinkFollow(follow?: FollowMode): SymlinkFollowMode | undefined { + if (!follow) { + return undefined; + } + + switch (follow) { + case FollowMode.NEVER: return SymlinkFollowMode.NEVER; + case FollowMode.ALWAYS: return SymlinkFollowMode.ALWAYS; + case FollowMode.BLOCK_EXTERNAL: return SymlinkFollowMode.BLOCK_EXTERNAL; + case FollowMode.EXTERNAL: return SymlinkFollowMode.EXTERNAL; + default: + throw new Error(`unknown follow mode: ${follow}`); + } +} diff --git a/packages/@aws-cdk/aws-s3-assets/test/alpine-markdown/Dockerfile b/packages/@aws-cdk/aws-s3-assets/test/alpine-markdown/Dockerfile new file mode 100644 index 0000000000000..fa7a67678bae9 --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/alpine-markdown/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine + +RUN apk add markdown diff --git a/packages/@aws-cdk/aws-s3-assets/test/compat.test.ts b/packages/@aws-cdk/aws-s3-assets/test/compat.test.ts new file mode 100644 index 0000000000000..41fbf0b57ac53 --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/compat.test.ts @@ -0,0 +1,11 @@ +import { FollowMode } from '@aws-cdk/assets'; +import { SymlinkFollowMode } from '@aws-cdk/core'; +import { toSymlinkFollow } from '../lib/compat'; + +test('FollowMode compatibility', () => { + expect(toSymlinkFollow(undefined)).toBeUndefined(); + expect(toSymlinkFollow(FollowMode.ALWAYS)).toBe(SymlinkFollowMode.ALWAYS); + expect(toSymlinkFollow(FollowMode.BLOCK_EXTERNAL)).toBe(SymlinkFollowMode.BLOCK_EXTERNAL); + expect(toSymlinkFollow(FollowMode.EXTERNAL)).toBe(SymlinkFollowMode.EXTERNAL); + expect(toSymlinkFollow(FollowMode.NEVER)).toBe(SymlinkFollowMode.NEVER); +}); diff --git a/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.expected.json b/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.expected.json new file mode 100644 index 0000000000000..21d2d76dbd488 --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.expected.json @@ -0,0 +1,78 @@ +{ + "Parameters": { + "AssetParameters10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8S3Bucket8CD0F73B": { + "Type": "String", + "Description": "S3 bucket for asset \"10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8\"" + }, + "AssetParameters10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8S3VersionKeyA9EAF743": { + "Type": "String", + "Description": "S3 key for asset version \"10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8\"" + }, + "AssetParameters10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8ArtifactHashBAE492DD": { + "Type": "String", + "Description": "Artifact hash for asset \"10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8\"" + } + }, + "Resources": { + "MyUserDC45028B": { + "Type": "AWS::IAM::User" + }, + "MyUserDefaultPolicy7B897426": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8S3Bucket8CD0F73B" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters10af79ff4bd6432db05b51810586c19ca95abd08759fca785e44f594bc9633b8S3Bucket8CD0F73B" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyUserDefaultPolicy7B897426", + "Users": [ + { + "Ref": "MyUserDC45028B" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.ts b/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.ts new file mode 100644 index 0000000000000..b1b144f2de275 --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/integ.assets.bundling.lit.ts @@ -0,0 +1,31 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { App, BundlingDockerImage, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as path from 'path'; +import * as assets from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + /// !show + const asset = new assets.Asset(this, 'BundledAsset', { + path: path.join(__dirname, 'markdown-asset'), // /asset-input and working directory in the container + bundling: { + image: BundlingDockerImage.fromAsset(path.join(__dirname, 'alpine-markdown')), // Build an image + command: [ + 'sh', '-c', ` + markdown index.md > /asset-output/index.html + `, + ], + }, + }); + /// !hide + + const user = new iam.User(this, 'MyUser'); + asset.grantRead(user); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-assets-bundling'); +app.synth(); diff --git a/packages/@aws-cdk/aws-s3-assets/test/markdown-asset/index.md b/packages/@aws-cdk/aws-s3-assets/test/markdown-asset/index.md new file mode 100644 index 0000000000000..64fdacbb595cb --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/markdown-asset/index.md @@ -0,0 +1,3 @@ +### This is a sample file + +With **markdown** diff --git a/packages/@aws-cdk/aws-ses-actions/package.json b/packages/@aws-cdk/aws-ses-actions/package.json index 92472cec0d6bf..98ceb9b2cd0b6 100644 --- a/packages/@aws-cdk/aws-ses-actions/package.json +++ b/packages/@aws-cdk/aws-ses-actions/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-sns-subscriptions/package.json b/packages/@aws-cdk/aws-sns-subscriptions/package.json index 07ea20698e7ed..13535b66faf0a 100644 --- a/packages/@aws-cdk/aws-sns-subscriptions/package.json +++ b/packages/@aws-cdk/aws-sns-subscriptions/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/package.json b/packages/@aws-cdk/aws-stepfunctions-tasks/package.json index 8f62cc2143eb5..7a8d6299c4072 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/package.json +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@aws-cdk/core/lib/asset-staging.ts b/packages/@aws-cdk/core/lib/asset-staging.ts index 0fb9dc3da8265..c37d8d441d7c0 100644 --- a/packages/@aws-cdk/core/lib/asset-staging.ts +++ b/packages/@aws-cdk/core/lib/asset-staging.ts @@ -1,13 +1,16 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; +import { AssetHashType, AssetOptions } from './assets'; +import { BUNDLING_INPUT_DIR, BUNDLING_OUTPUT_DIR, BundlingOptions } from './bundling'; import { Construct, ISynthesisSession } from './construct-compat'; import { FileSystem, FingerprintOptions } from './fs'; /** * Initialization properties for `AssetStaging`. */ -export interface AssetStagingProps extends FingerprintOptions { +export interface AssetStagingProps extends FingerprintOptions, AssetOptions { /** * The source file or directory to copy from. */ @@ -33,7 +36,6 @@ export interface AssetStagingProps extends FingerprintOptions { * means that only if content was changed, copy will happen. */ export class AssetStaging extends Construct { - /** * The path to the asset (stringinfied token). * @@ -48,43 +50,80 @@ export class AssetStaging extends Construct { public readonly sourcePath: string; /** - * A cryptographic hash of the source document(s). + * A cryptographic hash of the asset. + * + * @deprecated see `assetHash`. */ public readonly sourceHash: string; + /** + * A cryptographic hash of the asset. + */ + public readonly assetHash: string; + private readonly fingerprintOptions: FingerprintOptions; private readonly relativePath?: string; + private readonly bundleDir?: string; + constructor(scope: Construct, id: string, props: AssetStagingProps) { super(scope, id); this.sourcePath = props.sourcePath; this.fingerprintOptions = props; - this.sourceHash = FileSystem.fingerprint(this.sourcePath, props); + + if (props.bundling) { + this.bundleDir = this.bundle(props.bundling); + } + + this.assetHash = this.calculateHash(props); const stagingDisabled = this.node.tryGetContext(cxapi.DISABLE_ASSET_STAGING_CONTEXT); if (stagingDisabled) { - this.stagedPath = this.sourcePath; + this.stagedPath = this.bundleDir ?? this.sourcePath; } else { - this.relativePath = 'asset.' + this.sourceHash + path.extname(this.sourcePath); - this.stagedPath = this.relativePath; // always relative to outdir + this.relativePath = `asset.${this.assetHash}${path.extname(this.bundleDir ?? this.sourcePath)}`; + this.stagedPath = this.relativePath; } + + this.sourceHash = this.assetHash; } protected synthesize(session: ISynthesisSession) { + // Staging is disabled if (!this.relativePath) { return; } const targetPath = path.join(session.assembly.outdir, this.relativePath); - // asset already staged + // Already staged if (fs.existsSync(targetPath)) { return; } - // copy file/directory to staging directory + // Asset has been bundled + if (this.bundleDir) { + // Try to rename bundling directory to staging directory + try { + fs.renameSync(this.bundleDir, targetPath); + return; + } catch (err) { + // /tmp and cdk.out could be mounted across different mount points + // in this case we will fallback to copying. This can happen in Windows + // Subsystem for Linux (WSL). + if (err.code === 'EXDEV') { + fs.mkdirSync(targetPath); + FileSystem.copyDirectory(this.bundleDir, targetPath, this.fingerprintOptions); + return; + } + + throw err; + } + } + + // Copy file/directory to staging directory const stat = fs.statSync(this.sourcePath); if (stat.isFile()) { fs.copyFileSync(this.sourcePath, targetPath); @@ -95,4 +134,71 @@ export class AssetStaging extends Construct { throw new Error(`Unknown file type: ${this.sourcePath}`); } } + + private bundle(options: BundlingOptions): string { + // Create temporary directory for bundling + const bundleDir = fs.mkdtempSync(path.resolve(path.join(os.tmpdir(), 'cdk-asset-bundle-'))); + + // Always mount input and output dir + const volumes = [ + { + hostPath: this.sourcePath, + containerPath: BUNDLING_INPUT_DIR, + }, + { + hostPath: bundleDir, + containerPath: BUNDLING_OUTPUT_DIR, + }, + ...options.volumes ?? [], + ]; + + try { + options.image._run({ + command: options.command, + volumes, + environment: options.environment, + workingDirectory: options.workingDirectory ?? BUNDLING_INPUT_DIR, + }); + } catch (err) { + throw new Error(`Failed to run bundling Docker image for asset ${this.node.path}: ${err}`); + } + + if (FileSystem.isEmpty(bundleDir)) { + throw new Error(`Bundling did not produce any output. Check that your container writes content to ${BUNDLING_OUTPUT_DIR}.`); + } + + return bundleDir; + } + + private calculateHash(props: AssetStagingProps): string { + let hashType: AssetHashType; + + if (props.assetHash) { + if (props.assetHashType && props.assetHashType !== AssetHashType.CUSTOM) { + throw new Error(`Cannot specify \`${props.assetHashType}\` for \`assetHashType\` when \`assetHash\` is specified. Use \`CUSTOM\` or leave \`undefined\`.`); + } + hashType = AssetHashType.CUSTOM; + } else if (props.assetHashType) { + hashType = props.assetHashType; + } else { + hashType = AssetHashType.SOURCE; + } + + switch (hashType) { + case AssetHashType.SOURCE: + return FileSystem.fingerprint(this.sourcePath, this.fingerprintOptions); + case AssetHashType.BUNDLE: + if (!this.bundleDir) { + throw new Error('Cannot use `AssetHashType.BUNDLE` when `bundling` is not specified.'); + } + return FileSystem.fingerprint(this.bundleDir, this.fingerprintOptions); + case AssetHashType.CUSTOM: + if (!props.assetHash) { + throw new Error('`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`.'); + } + return props.assetHash; + default: + throw new Error('Unknown asset hash type.'); + } + } } diff --git a/packages/@aws-cdk/core/lib/assets.ts b/packages/@aws-cdk/core/lib/assets.ts index 8c59e576b588c..bad303dbd8c31 100644 --- a/packages/@aws-cdk/core/lib/assets.ts +++ b/packages/@aws-cdk/core/lib/assets.ts @@ -1,3 +1,81 @@ +import { BundlingOptions } from './bundling'; + +/** + * Common interface for all assets. + */ +export interface IAsset { + /** + * A hash of this asset, which is available at construction time. As this is a plain string, it + * can be used in construct IDs in order to enforce creation of a new resource when the content + * hash has changed. + */ + readonly assetHash: string; +} + +/** + * Asset hash options + */ +export interface AssetOptions { + /** + * Specify a custom hash for this asset. If `assetHashType` is set it must + * be set to `AssetHashType.CUSTOM`. + * + * NOTE: the hash is used in order to identify a specific revision of the asset, and + * used for optimizing and caching deployment activities related to this asset such as + * packaging, uploading to Amazon S3, etc. If you chose to customize the hash, you will + * need to make sure it is updated every time the asset changes, or otherwise it is + * possible that some deployments will not be invalidated. + * + * @default - based on `assetHashType` + */ + readonly assetHash?: string; + + /** + * Specifies the type of hash to calculate for this asset. + * + * If `assetHash` is configured, this option must be `undefined` or + * `AssetHashType.CUSTOM`. + * + * @default - the default is `AssetHashType.SOURCE`, but if `assetHash` is + * explicitly specified this value defaults to `AssetHashType.CUSTOM`. + */ + readonly assetHashType?: AssetHashType; + + /** + * Bundle the asset by executing a command in a Docker container. + * The asset path will be mounted at `/asset-input`. The Docker + * container is responsible for putting content at `/asset-output`. + * The content at `/asset-output` will be zipped and used as the + * final asset. + * + * @default - uploaded as-is to S3 if the asset is a regular file or a .zip file, + * archived into a .zip file and uploaded to S3 otherwise + * + * @experimental + */ + readonly bundling?: BundlingOptions; +} + +/** + * The type of asset hash + */ +export enum AssetHashType { + /** + * Based on the content of the source path + */ + SOURCE = 'source', + + /** + * Based on the content of the bundled path + */ + BUNDLE = 'bundle', + + /** + * Use a custom hash + */ + CUSTOM = 'custom', +} + /** * Represents the source for a file asset. */ diff --git a/packages/@aws-cdk/core/lib/bundling.ts b/packages/@aws-cdk/core/lib/bundling.ts new file mode 100644 index 0000000000000..bfff68b40f5cd --- /dev/null +++ b/packages/@aws-cdk/core/lib/bundling.ts @@ -0,0 +1,193 @@ +import { spawnSync } from 'child_process'; + +export const BUNDLING_INPUT_DIR = '/asset-input'; +export const BUNDLING_OUTPUT_DIR = '/asset-output'; + +/** + * Bundling options + * + * @experimental + */ +export interface BundlingOptions { + /** + * The Docker image where the command will run. + */ + readonly image: BundlingDockerImage; + + /** + * The command to run in the container. + * + * @example ['npm', 'install'] + * + * @see https://docs.docker.com/engine/reference/run/ + * + * @default - run the command defined in the image + */ + readonly command?: string[]; + + /** + * Additional Docker volumes to mount. + * + * @default - no additional volumes are mounted + */ + readonly volumes?: DockerVolume[]; + + /** + * The environment variables to pass to the container. + * + * @default - no environment variables. + */ + readonly environment?: { [key: string]: string; }; + + /** + * Working directory inside the container. + * + * @default /asset-input + */ + readonly workingDirectory?: string; +} + +/** + * A Docker image used for asset bundling + */ +export class BundlingDockerImage { + /** + * Reference an image on DockerHub or another online registry. + * + * @param image the image name + */ + public static fromRegistry(image: string) { + return new BundlingDockerImage(image); + } + + /** + * Reference an image that's built directly from sources on disk. + * + * @param path The path to the directory containing the Docker file + * @param options Docker build options + */ + public static fromAsset(path: string, options: DockerBuildOptions = {}) { + const buildArgs = options.buildArgs || {}; + + const dockerArgs: string[] = [ + 'build', + ...flatten(Object.entries(buildArgs).map(([k, v]) => ['--build-arg', `${k}=${v}`])), + path, + ]; + + const docker = exec('docker', dockerArgs); + + const match = docker.stdout.toString().match(/Successfully built ([a-z0-9]+)/); + + if (!match) { + throw new Error('Failed to extract image ID from Docker build output'); + } + + return new BundlingDockerImage(match[1]); + } + + /** @param image The Docker image */ + private constructor(public readonly image: string) {} + + /** + * Runs a Docker image + * + * @internal + */ + public _run(options: DockerRunOptions = {}) { + const volumes = options.volumes || []; + const environment = options.environment || {}; + const command = options.command || []; + + const dockerArgs: string[] = [ + 'run', '--rm', + ...flatten(volumes.map(v => ['-v', `${v.hostPath}:${v.containerPath}`])), + ...flatten(Object.entries(environment).map(([k, v]) => ['--env', `${k}=${v}`])), + ...options.workingDirectory + ? ['-w', options.workingDirectory] + : [], + this.image, + ...command, + ]; + + exec('docker', dockerArgs); + } +} + +/** + * A Docker volume + */ +export interface DockerVolume { + /** + * The path to the file or directory on the host machine + */ + readonly hostPath: string; + + /** + * The path where the file or directory is mounted in the container + */ + readonly containerPath: string; +} + +/** + * Docker run options + */ +interface DockerRunOptions { + /** + * The command to run in the container. + * + * @default - run the command defined in the image + */ + readonly command?: string[]; + + /** + * Docker volumes to mount. + * + * @default - no volumes are mounted + */ + readonly volumes?: DockerVolume[]; + + /** + * The environment variables to pass to the container. + * + * @default - no environment variables. + */ + readonly environment?: { [key: string]: string; }; + + /** + * Working directory inside the container. + * + * @default - image default + */ + readonly workingDirectory?: string; +} + +/** + * Docker build options + */ +export interface DockerBuildOptions { + /** + * Build args + * + * @default - no build args + */ + readonly buildArgs?: { [key: string]: string }; +} + +function flatten(x: string[][]) { + return Array.prototype.concat([], ...x); +} + +function exec(cmd: string, args: string[]) { + const proc = spawnSync(cmd, args); + + if (proc.error) { + throw proc.error; + } + + if (proc.status !== 0) { + throw new Error(`[Status ${proc.status}] stdout: ${proc.stdout?.toString().trim()}\n\n\nstderr: ${proc.stderr?.toString().trim()}`); + } + + return proc; +} diff --git a/packages/@aws-cdk/core/lib/fs/index.ts b/packages/@aws-cdk/core/lib/fs/index.ts index ac7f3c9d0f8da..01c6d132956e2 100644 --- a/packages/@aws-cdk/core/lib/fs/index.ts +++ b/packages/@aws-cdk/core/lib/fs/index.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import { copyDirectory } from './copy'; import { fingerprint } from './fingerprint'; import { CopyOptions, FingerprintOptions } from './options'; @@ -33,4 +34,13 @@ export class FileSystem { public static fingerprint(fileOrDirectory: string, options: FingerprintOptions = { }) { return fingerprint(fileOrDirectory, options); } -} \ No newline at end of file + + /** + * Checks whether a directory is empty + * + * @param dir The directory to check + */ + public static isEmpty(dir: string): boolean { + return fs.readdirSync(dir).length === 0; + } +} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 8b238e0c721fd..6c54a222901d6 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -48,6 +48,7 @@ export * from './assets'; export * from './tree'; export * from './asset-staging'; +export * from './bundling'; export * from './fs'; export * from './custom-resource'; diff --git a/packages/@aws-cdk/core/package.json b/packages/@aws-cdk/core/package.json index a654253f2d938..c1066fd4799a6 100644 --- a/packages/@aws-cdk/core/package.json +++ b/packages/@aws-cdk/core/package.json @@ -152,15 +152,17 @@ "license": "Apache-2.0", "devDependencies": { "@types/lodash": "^4.14.155", - "@types/node": "^10.17.21", + "@types/node": "^10.17.25", "@types/nodeunit": "^0.0.31", "@types/minimatch": "^3.0.3", + "@types/sinon": "^9.0.4", "cdk-build-tools": "0.0.0", "cfn2ts": "0.0.0", "fast-check": "^1.24.2", "lodash": "^4.17.15", "nodeunit": "^0.11.3", "pkglint": "0.0.0", + "sinon": "^9.0.2", "ts-mock-imports": "^1.3.0" }, "dependencies": { diff --git a/packages/@aws-cdk/core/test/test.bundling.ts b/packages/@aws-cdk/core/test/test.bundling.ts new file mode 100644 index 0000000000000..658aa99901bb6 --- /dev/null +++ b/packages/@aws-cdk/core/test/test.bundling.ts @@ -0,0 +1,120 @@ +import * as child_process from 'child_process'; +import { Test } from 'nodeunit'; +import * as sinon from 'sinon'; +import { BundlingDockerImage } from '../lib'; + +export = { + 'tearDown'(callback: any) { + sinon.restore(); + callback(); + }, + + 'bundling with image from registry'(test: Test) { + const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const image = BundlingDockerImage.fromRegistry('alpine'); + image._run({ + command: ['cool', 'command'], + environment: { + VAR1: 'value1', + VAR2: 'value2', + }, + volumes: [{ hostPath: '/host-path', containerPath: '/container-path' }], + workingDirectory: '/working-directory', + }); + + test.ok(spawnSyncStub.calledWith('docker', [ + 'run', '--rm', + '-v', '/host-path:/container-path', + '--env', 'VAR1=value1', + '--env', 'VAR2=value2', + '-w', '/working-directory', + 'alpine', + 'cool', 'command', + ])); + test.done(); + }, + + 'bundling with image from asset'(test: Test) { + const imageId = 'abcdef123456'; + const spawnSyncStub = sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from(`Successfully built ${imageId}`), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const image = BundlingDockerImage.fromAsset('docker-path', { + buildArgs: { + TEST_ARG: 'cdk-test', + }, + }); + image._run(); + + test.ok(spawnSyncStub.firstCall.calledWith('docker', [ + 'build', + '--build-arg', 'TEST_ARG=cdk-test', + 'docker-path', + ])); + + test.ok(spawnSyncStub.secondCall.calledWith('docker', [ + 'run', '--rm', + imageId, + ])); + test.done(); + }, + + 'throws if image id cannot be extracted from build output'(test: Test) { + sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + test.throws(() => BundlingDockerImage.fromAsset('docker-path'), /Failed to extract image ID from Docker build output/); + test.done(); + }, + + 'throws in case of spawnSync error'(test: Test) { + sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + error: new Error('UnknownError'), + }); + + const image = BundlingDockerImage.fromRegistry('alpine'); + test.throws(() => image._run(), /UnknownError/); + test.done(); + }, + + 'throws if status is not 0'(test: Test) { + sinon.stub(child_process, 'spawnSync').returns({ + status: -1, + stderr: Buffer.from('stderr'), + stdout: Buffer.from('stdout'), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + const image = BundlingDockerImage.fromRegistry('alpine'); + test.throws(() => image._run(), /\[Status -1\]/); + test.done(); + }, +}; diff --git a/packages/@aws-cdk/core/test/test.staging.ts b/packages/@aws-cdk/core/test/test.staging.ts index 3faeea3e95396..5d5ab521eba59 100644 --- a/packages/@aws-cdk/core/test/test.staging.ts +++ b/packages/@aws-cdk/core/test/test.staging.ts @@ -2,7 +2,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs'; import { Test } from 'nodeunit'; import * as path from 'path'; -import { App, AssetStaging, Stack } from '../lib'; +import { App, AssetHashType, AssetStaging, BundlingDockerImage, Stack } from '../lib'; export = { 'base case'(test: Test) { @@ -74,4 +74,154 @@ export = { test.deepEqual(withExtra.sourceHash, 'c95c915a5722bb9019e2c725d11868e5a619b55f36172f76bcbcaa8bb2d10c5f'); test.done(); }, + + 'with bundling'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: ['touch', '/asset-output/test.txt'], + }, + }); + + // THEN + const assembly = app.synth(); + test.deepEqual(fs.readdirSync(assembly.directory), [ + 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00', + 'cdk.out', + 'manifest.json', + 'stack.template.json', + 'tree.json', + ]); + + test.done(); + }, + + 'bundling throws when /asset-ouput is empty'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // THEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + }, + }), /Bundling did not produce any output/); + + test.done(); + }, + + 'bundling with BUNDLE asset hash type'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + const asset = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: ['touch', '/asset-output/test.txt'], + }, + assetHashType: AssetHashType.BUNDLE, + }); + + test.equal(asset.assetHash, '33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); + + test.done(); + }, + + 'custom hash'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + const asset = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHash: 'my-custom-hash', + }); + + test.equal(asset.assetHash, 'my-custom-hash'); + + test.done(); + }, + + 'throws with assetHash and not CUSTOM hash type'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // THEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: ['touch', '/asset-output/test.txt'], + }, + assetHash: 'my-custom-hash', + assetHashType: AssetHashType.BUNDLE, + }), /Cannot specify `bundle` for `assetHashType`/); + + test.done(); + }, + + 'throws with BUNDLE hash type and no bundling'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // THEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.BUNDLE, + }), /Cannot use `AssetHashType.BUNDLE` when `bundling` is not specified/); + + test.done(); + }, + + 'throws with CUSTOM and no hash'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // THEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.CUSTOM, + }), /`assetHash` must be specified when `assetHashType` is set to `AssetHashType.CUSTOM`/); + + test.done(); + }, + + 'throws when bundling fails'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // THEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('this-is-an-invalid-docker-image'), + }, + }), /Failed to run bundling Docker image for asset stack\/Asset/); + + test.done(); + }, }; diff --git a/packages/@aws-cdk/custom-resources/package.json b/packages/@aws-cdk/custom-resources/package.json index 7cd16b421cb1b..561ac6ef58a6f 100644 --- a/packages/@aws-cdk/custom-resources/package.json +++ b/packages/@aws-cdk/custom-resources/package.json @@ -41,7 +41,6 @@ "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", - "cfn2ts": "cfn2ts", "build+test+package": "npm run build+test && npm run package", "build+test": "npm run build && npm test", "compat": "cdk-compat" diff --git a/packages/@monocdk-experiment/assert/package.json b/packages/@monocdk-experiment/assert/package.json index 5da40f1a0097f..762a4b67eb193 100644 --- a/packages/@monocdk-experiment/assert/package.json +++ b/packages/@monocdk-experiment/assert/package.json @@ -37,7 +37,7 @@ "license": "Apache-2.0", "devDependencies": { "@types/jest": "^25.2.3", - "@types/node": "^10.17.24", + "@types/node": "^10.17.25", "cdk-build-tools": "0.0.0", "jest": "^25.5.4", "pkglint": "0.0.0", diff --git a/packages/@monocdk-experiment/rewrite-imports/bin/rewrite-imports.ts b/packages/@monocdk-experiment/rewrite-imports/bin/rewrite-imports.ts index 360e526fbe175..a79f833d5e71c 100644 --- a/packages/@monocdk-experiment/rewrite-imports/bin/rewrite-imports.ts +++ b/packages/@monocdk-experiment/rewrite-imports/bin/rewrite-imports.ts @@ -3,10 +3,9 @@ import * as fs from 'fs'; import * as _glob from 'glob'; import { promisify } from 'util'; -import { rewriteFile } from '../lib/rewrite'; +import { rewriteImports } from '../lib/rewrite'; + const glob = promisify(_glob); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); async function main() { if (!process.argv[2]) { @@ -21,10 +20,10 @@ async function main() { const files = await glob(process.argv[2], { ignore, matchBase: true }); for (const file of files) { - const input = await readFile(file, 'utf-8'); - const output = rewriteFile(input); + const input = await fs.promises.readFile(file, { encoding: 'utf8' }); + const output = rewriteImports(input, file); if (output.trim() !== input.trim()) { - await writeFile(file, output); + await fs.promises.writeFile(file, output); } } } diff --git a/packages/@monocdk-experiment/rewrite-imports/lib/rewrite.ts b/packages/@monocdk-experiment/rewrite-imports/lib/rewrite.ts index 35b78943f6444..f6dad5edbd89b 100644 --- a/packages/@monocdk-experiment/rewrite-imports/lib/rewrite.ts +++ b/packages/@monocdk-experiment/rewrite-imports/lib/rewrite.ts @@ -1,24 +1,113 @@ -const exclude = [ - '@aws-cdk/cloudformation-diff', - '@aws-cdk/assert', -]; +import * as ts from 'typescript'; + +/** + * Re-writes "hyper-modular" CDK imports (most packages in `@aws-cdk/*`) to the + * relevant "mono" CDK import path. The re-writing will only modify the imported + * library path, presrving the existing quote style, etc... + * + * Syntax errors in the source file being processed may cause some import + * statements to not be re-written. + * + * Supported import statement forms are: + * - `import * as lib from '@aws-cdk/lib';` + * - `import { Type } from '@aws-cdk/lib';` + * - `import '@aws-cdk/lib';` + * - `import lib = require('@aws-cdk/lib');` + * - `import { Type } = require('@aws-cdk/lib'); + * - `require('@aws-cdk/lib'); + * + * @param sourceText the source code where imports should be re-written. + * @param fileName a customized file name to provide the TypeScript processor. + * + * @returns the updated source code. + */ +export function rewriteImports(sourceText: string, fileName: string = 'index.ts'): string { + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2018); + + const replacements = new Array<{ original: ts.Node, updatedLocation: string }>(); + + const visitor = (node: T): ts.VisitResult => { + const moduleSpecifier = getModuleSpecifier(node); + const newTarget = moduleSpecifier && updatedLocationOf(moduleSpecifier.text); + + if (moduleSpecifier != null && newTarget != null) { + replacements.push({ original: moduleSpecifier, updatedLocation: newTarget }); + } -export function rewriteFile(source: string) { - const output = new Array(); - for (const line of source.split('\n')) { - output.push(rewriteLine(line)); + return node; + }; + + sourceFile.statements.forEach(node => ts.visitNode(node, visitor)); + + let updatedSourceText = sourceText; + // Applying replacements in reverse order, so node positions remain valid. + for (const replacement of replacements.sort(({ original: l }, { original: r }) => r.getStart(sourceFile) - l.getStart(sourceFile))) { + const prefix = updatedSourceText.substring(0, replacement.original.getStart(sourceFile) + 1); + const suffix = updatedSourceText.substring(replacement.original.getEnd() - 1); + + updatedSourceText = prefix + replacement.updatedLocation + suffix; } - return output.join('\n'); -} -export function rewriteLine(line: string) { - for (const skip of exclude) { - if (line.includes(skip)) { - return line; + return updatedSourceText; + + function getModuleSpecifier(node: ts.Node): ts.StringLiteral | undefined { + if (ts.isImportDeclaration(node)) { + // import style + const moduleSpecifier = node.moduleSpecifier; + if (ts.isStringLiteral(moduleSpecifier)) { + // import from 'location'; + // import * as name from 'location'; + return moduleSpecifier; + } else if (ts.isBinaryExpression(moduleSpecifier) && ts.isCallExpression(moduleSpecifier.right)) { + // import { Type } = require('location'); + return getModuleSpecifier(moduleSpecifier.right); + } + } else if ( + ts.isImportEqualsDeclaration(node) + && ts.isExternalModuleReference(node.moduleReference) + && ts.isStringLiteral(node.moduleReference.expression) + ) { + // import name = require('location'); + return node.moduleReference.expression; + } else if ( + (ts.isCallExpression(node)) + && ts.isIdentifier(node.expression) + && node.expression.escapedText === 'require' + && node.arguments.length === 1 + ) { + // require('location'); + const argument = node.arguments[0]; + if (ts.isStringLiteral(argument)) { + return argument; + } + } else if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) { + // require('location'); // This is an alternate AST version of it + return getModuleSpecifier(node.expression); } + return undefined; } - return line - .replace(/(["'])@aws-cdk\/assert(["'])/g, '$1@monocdk-experiment/assert$2') // @aws-cdk/assert => @monocdk-experiment/assert - .replace(/(["'])@aws-cdk\/core(["'])/g, '$1monocdk-experiment$2') // @aws-cdk/core => monocdk-experiment - .replace(/(["'])@aws-cdk\/(.+)(["'])/g, '$1monocdk-experiment/$2$3'); // @aws-cdk/* => monocdk-experiment/*; +} + +const EXEMPTIONS = new Set([ + '@aws-cdk/cloudformation-diff', +]); + +function updatedLocationOf(modulePath: string): string | undefined { + if (!modulePath.startsWith('@aws-cdk/') || EXEMPTIONS.has(modulePath)) { + return undefined; + } + + if (modulePath === '@aws-cdk/core') { + return 'monocdk-experiment'; + } + + if (modulePath === '@aws-cdk/assert') { + return '@monocdk-experiment/assert'; + } + + if (modulePath === '@aws-cdk/assert/jest') { + return '@monocdk-experiment/assert/jest'; + } + + return `monocdk-experiment/${modulePath.substring(9)}`; } diff --git a/packages/@monocdk-experiment/rewrite-imports/package.json b/packages/@monocdk-experiment/rewrite-imports/package.json index ac8731f60ff8d..d9a1f74eb18e8 100644 --- a/packages/@monocdk-experiment/rewrite-imports/package.json +++ b/packages/@monocdk-experiment/rewrite-imports/package.json @@ -31,12 +31,13 @@ }, "license": "Apache-2.0", "dependencies": { - "glob": "^7.1.6" + "glob": "^7.1.6", + "typescript": "~3.8.3" }, "devDependencies": { "@types/glob": "^7.1.1", "@types/jest": "^25.2.3", - "@types/node": "^10.17.21", + "@types/node": "^10.17.25", "cdk-build-tools": "0.0.0", "pkglint": "0.0.0" }, diff --git a/packages/@monocdk-experiment/rewrite-imports/test/rewrite.test.ts b/packages/@monocdk-experiment/rewrite-imports/test/rewrite.test.ts index d282338f66c4b..689efb72ef79b 100644 --- a/packages/@monocdk-experiment/rewrite-imports/test/rewrite.test.ts +++ b/packages/@monocdk-experiment/rewrite-imports/test/rewrite.test.ts @@ -1,47 +1,75 @@ -import { rewriteFile, rewriteLine } from '../lib/rewrite'; +import { rewriteImports } from '../lib/rewrite'; -describe('rewriteLine', () => { - test('quotes', () => { - expect(rewriteLine('import * as s3 from \'@aws-cdk/aws-s3\'')) - .toEqual('import * as s3 from \'monocdk-experiment/aws-s3\''); - }); +describe(rewriteImports, () => { + test('correctly rewrites naked "import"', () => { + const output = rewriteImports(` + // something before + import '@aws-cdk/assert/jest'; + // something after - test('double quotes', () => { - expect(rewriteLine('import * as s3 from "@aws-cdk/aws-s3"')) - .toEqual('import * as s3 from "monocdk-experiment/aws-s3"'); - }); + console.log('Look! I did something!');`, 'subhect.ts'); + + expect(output).toBe(` + // something before + import '@monocdk-experiment/assert/jest'; + // something after - test('@aws-cdk/core', () => { - expect(rewriteLine('import * as s3 from "@aws-cdk/core"')) - .toEqual('import * as s3 from "monocdk-experiment"'); - expect(rewriteLine('import * as s3 from \'@aws-cdk/core\'')) - .toEqual('import * as s3 from \'monocdk-experiment\''); + console.log('Look! I did something!');`); }); - test('non-jsii modules are ignored', () => { - expect(rewriteLine('import * as cfndiff from \'@aws-cdk/cloudformation-diff\'')) - .toEqual('import * as cfndiff from \'@aws-cdk/cloudformation-diff\''); - expect(rewriteLine('import * as cfndiff from \'@aws-cdk/assert')) - .toEqual('import * as cfndiff from \'@aws-cdk/assert'); + test('correctly rewrites naked "require"', () => { + const output = rewriteImports(` + // something before + require('@aws-cdk/assert/jest'); + // something after + + console.log('Look! I did something!');`, 'subhect.ts'); + + expect(output).toBe(` + // something before + require('@monocdk-experiment/assert/jest'); + // something after + + console.log('Look! I did something!');`); }); -}); -describe('rewriteFile', () => { - const output = rewriteFile(` + test('correctly rewrites "import from"', () => { + const output = rewriteImports(` // something before import * as s3 from '@aws-cdk/aws-s3'; import * as cfndiff from '@aws-cdk/cloudformation-diff'; - import * as s3 from '@aws-cdk/core'; + import { Construct } from "@aws-cdk/core"; // something after - // hello`); + console.log('Look! I did something!');`, 'subject.ts'); - expect(output).toEqual(` + expect(output).toBe(` // something before import * as s3 from 'monocdk-experiment/aws-s3'; import * as cfndiff from '@aws-cdk/cloudformation-diff'; - import * as s3 from 'monocdk-experiment'; + import { Construct } from "monocdk-experiment"; + // something after + + console.log('Look! I did something!');`); + }); + + test('correctly rewrites "import = require"', () => { + const output = rewriteImports(` + // something before + import s3 = require('@aws-cdk/aws-s3'); + import cfndiff = require('@aws-cdk/cloudformation-diff'); + import { Construct } = require("@aws-cdk/core"); // something after - // hello`); -}); \ No newline at end of file + console.log('Look! I did something!');`, 'subject.ts'); + + expect(output).toBe(` + // something before + import s3 = require('monocdk-experiment/aws-s3'); + import cfndiff = require('@aws-cdk/cloudformation-diff'); + import { Construct } = require("monocdk-experiment"); + // something after + + console.log('Look! I did something!');`); + }); +}); diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 488b57c0d0a13..7a056f1b1ba02 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -47,7 +47,7 @@ "@types/jest": "^25.2.3", "@types/minimatch": "^3.0.3", "@types/mockery": "^1.4.29", - "@types/node": "^10.17.21", + "@types/node": "^10.17.25", "@types/promptly": "^3.0.0", "@types/semver": "^7.2.0", "@types/sinon": "^9.0.4", @@ -100,7 +100,7 @@ ], "homepage": "https://github.com/aws/aws-cdk", "engines": { - "node": ">= 10.13.0 <13 || >=13.7.0" + "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "stable", "maturity": "stable" diff --git a/packages/cdk-assets/package.json b/packages/cdk-assets/package.json index eb67aa7d3b6f8..91c272bb380c8 100644 --- a/packages/cdk-assets/package.json +++ b/packages/cdk-assets/package.json @@ -16,7 +16,6 @@ "pkglint": "pkglint -f", "test": "cdk-test", "watch": "cdk-watch", - "cfn2ts": "cfn2ts", "build+test": "npm run build && npm test", "build+test+package": "npm run build+test && npm run package", "compat": "cdk-compat" @@ -35,7 +34,7 @@ "@types/glob": "^7.1.1", "@types/jest": "^25.2.3", "@types/mock-fs": "^4.10.0", - "@types/node": "^10.17.21", + "@types/node": "^10.17.25", "@types/yargs": "^15.0.5", "@types/jszip": "^3.4.1", "jszip": "^3.4.0", diff --git a/packages/monocdk-experiment/package.json b/packages/monocdk-experiment/package.json index 6979ba08618d3..24d6079e29cee 100644 --- a/packages/monocdk-experiment/package.json +++ b/packages/monocdk-experiment/package.json @@ -246,7 +246,7 @@ "@aws-cdk/cx-api": "0.0.0", "@aws-cdk/region-info": "0.0.0", "@types/fs-extra": "^8.1.1", - "@types/node": "^10.17.24", + "@types/node": "^10.17.25", "cdk-build-tools": "0.0.0", "fs-extra": "^9.0.1", "pkglint": "0.0.0", diff --git a/tools/pkglint/lib/rules.ts b/tools/pkglint/lib/rules.ts index 48c613758bfee..53bb5e5a894b3 100644 --- a/tools/pkglint/lib/rules.ts +++ b/tools/pkglint/lib/rules.ts @@ -1005,12 +1005,8 @@ export class Cfn2Ts extends ValidationRule { public readonly name = 'cfn2ts'; public validate(pkg: PackageJson) { - if (!isJSII(pkg)) { - return; - } - - if (!isAWS(pkg)) { - return; + if (!isJSII(pkg) || !isAWS(pkg)) { + return expectJSON(this.name, pkg, 'scripts.cfn2ts', undefined); } expectJSON(this.name, pkg, 'scripts.cfn2ts', 'cfn2ts'); @@ -1253,7 +1249,7 @@ function isJSII(pkg: PackageJson): boolean { * @param pkg */ function isAWS(pkg: PackageJson): boolean { - return pkg.json['cdk-build'] && pkg.json['cdk-build'].cloudformation; + return pkg.json['cdk-build']?.cloudformation != null; } /** diff --git a/tools/pkglint/package.json b/tools/pkglint/package.json index 5ce02cb64d1df..2118a2c677bb6 100644 --- a/tools/pkglint/package.json +++ b/tools/pkglint/package.json @@ -17,9 +17,8 @@ }, "scripts": { "build": "tsc -b && tslint -p . && chmod +x bin/pkglint", - "test": "echo success", - "build+test": "npm run build && npm test", - "build+test+package": "npm run build+test", + "build+test": "npm run build", + "build+test+package": "npm run build", "watch": "tsc -b -w", "lint": "tsc -b && tslint -p . --force" }, @@ -37,7 +36,8 @@ "devDependencies": { "@types/fs-extra": "^8.1.0", "@types/semver": "^7.2.0", - "@types/yargs": "^15.0.5" + "@types/yargs": "^15.0.5", + "typescript": "~3.8.3" }, "dependencies": { "case": "^1.6.3", diff --git a/tools/yarn-cling/.eslintrc.js b/tools/yarn-cling/.eslintrc.js new file mode 100644 index 0000000000000..0c60e21090199 --- /dev/null +++ b/tools/yarn-cling/.eslintrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('../tools/cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +module.exports = baseConfig; diff --git a/tools/yarn-cling/.gitignore b/tools/yarn-cling/.gitignore index d05ddbf403f73..2d8e8a2d36377 100644 --- a/tools/yarn-cling/.gitignore +++ b/tools/yarn-cling/.gitignore @@ -6,3 +6,8 @@ dist .LAST_BUILD *.snk !jest.config.js + +.nyc_output +coverage +nyc.config.js +!.eslintrc.js \ No newline at end of file diff --git a/tools/yarn-cling/.npmignore b/tools/yarn-cling/.npmignore index e049d31151c8f..af12b026f1401 100644 --- a/tools/yarn-cling/.npmignore +++ b/tools/yarn-cling/.npmignore @@ -8,3 +8,5 @@ coverage .LAST_BUILD *.snk jest.config.js + +.eslintrc.js \ No newline at end of file diff --git a/tools/yarn-cling/lib/index.ts b/tools/yarn-cling/lib/index.ts index 816f55e88e97d..eabbf390c5207 100644 --- a/tools/yarn-cling/lib/index.ts +++ b/tools/yarn-cling/lib/index.ts @@ -31,7 +31,7 @@ export async function generateShrinkwrap(options: ShrinkwrapOptions): Promise { - return JSON.parse(await fs.readFile(fileName, { encoding: 'utf-8' })); + return JSON.parse(await fs.readFile(fileName, { encoding: 'utf8' })); } async function fileExists(fullPath: string): Promise { diff --git a/tools/yarn-cling/package.json b/tools/yarn-cling/package.json index 08403bca30b31..2c87c0e9467ec 100644 --- a/tools/yarn-cling/package.json +++ b/tools/yarn-cling/package.json @@ -29,11 +29,19 @@ "organization": true }, "license": "Apache-2.0", + "pkglint": { + "exclude": [ + "dependencies/build-tools", + "package-info/scripts/build", + "package-info/scripts/watch", + "package-info/scripts/test" + ] + }, "devDependencies": { "@types/yarnpkg__lockfile": "^1.1.3", "@types/jest": "^25.2.3", "jest": "^25.5.4", - "@types/node": "^13.9.1", + "@types/node": "^10.17.25", "typescript": "~3.8.3", "pkglint": "0.0.0" }, @@ -46,6 +54,6 @@ ], "homepage": "https://github.com/aws/aws-cdk", "engines": { - "node": ">= 10.3.0" + "node": ">= 10.13.0 <13 || >=13.7.0" } } diff --git a/tools/yarn-cling/test/test-fixture/.no-packagejson-validator b/tools/yarn-cling/test/test-fixture/.no-packagejson-validator new file mode 100644 index 0000000000000..6824459f6c5e0 --- /dev/null +++ b/tools/yarn-cling/test/test-fixture/.no-packagejson-validator @@ -0,0 +1 @@ +Test fixtures should not be affected. diff --git a/yarn.lock b/yarn.lock index 92848de826535..3434b584d847c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1546,20 +1546,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.0.tgz#30d2d09f623fe32cde9cb582c7a6eda2788ce4a8" integrity sha512-WE4IOAC6r/yBZss1oQGM5zs2D7RuKR6Q+w+X2SouPofnWn+LbCqClRyhO3ZE7Ix8nmFgo/oVuuE01cJT2XB13A== -"@types/node@^10.17.21": - version "10.17.21" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.21.tgz#c00e9603399126925806bed2d9a1e37da506965e" - integrity sha512-PQKsydPxYxF1DsAFWmunaxd3sOi3iMt6Zmx/tgaagHYmwJ/9cRH91hQkeJZaUGWbvn0K5HlSVEXkn5U/llWPpQ== - -"@types/node@^10.17.24": - version "10.17.24" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944" - integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA== - -"@types/node@^13.9.1": - version "13.13.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.9.tgz#79df4ae965fb76d31943b54a6419599307a21394" - integrity sha512-EPZBIGed5gNnfWCiwEIwTE2Jdg4813odnG8iNPMQGrqVxrI+wL68SPtPeCX+ZxGBaA6pKAVc6jaKgP/Q0QzfdQ== +"@types/node@^10.17.25": + version "10.17.25" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.25.tgz#64f64cd3e8641e8163c81045e545d2825d300e37" + integrity sha512-EWPw3jDB0jip4HafDkoezNOwG00TtVZ1TOe74MaxIBWgpyM60UF/LXzFVx9+8AdSYNNOPgx7TuJoRmgnhHZ/7g== "@types/nodeunit@^0.0.31": version "0.0.31"