packages/awslint/lib/rules/resource.ts (204 lines of code) (raw):

import * as reflect from 'jsii-reflect'; import { CfnResourceReflection } from './cfn-resource'; import { ConstructReflection } from './construct'; import { CoreTypes } from './core-types'; import { getDocTag } from './util'; import { camelize, pascalize } from '../case'; import { Linter } from '../linter'; const GRANT_RESULT_FQN = '@aws-cdk/aws-iam.Grant'; export const resourceLinter = new Linter(a => ResourceReflection.findAll(a)); export interface Attribute { site: AttributeSite; property: reflect.Property; cfnAttributeNames: string[]; // bucketArn } export enum AttributeSite { Interface = 'interface', Class = 'class', } export class ResourceReflection { /** * @returns all resource constructs (everything that extends `cdk.Resource`) */ public static findAll(assembly: reflect.Assembly) { if (CoreTypes.hasCoreModule(assembly)) { return []; // not part of the dep stack } return assembly.allClasses .filter(c => CoreTypes.isConstructClass(c) && CoreTypes.isResourceClass(c)) .map(c => new ResourceReflection(new ConstructReflection(c))); } public readonly attributes: Attribute[]; // actual attribute props public readonly fqn: string; // expected fqn of resource class public readonly assembly: reflect.Assembly; public readonly sys: reflect.TypeSystem; public readonly cfn: CfnResourceReflection; public readonly basename: string; // i.e. Bucket public readonly core: CoreTypes; public readonly physicalNameProp?: reflect.Property; constructor(public readonly construct: ConstructReflection) { this.assembly = construct.classType.assembly; this.sys = this.assembly.system; const cfn = tryResolveCfnResource(construct.classType); if (!cfn) { throw new Error(`Cannot find L1 class for L2 ${construct.fqn}. ` + `Is "${guessResourceName(construct.fqn)}" an actual CloudFormation resource. ` + 'If not, use the "@resource" doc tag to indicate the full resource name (e.g. "@resource AWS::Route53::HostedZone")'); } this.core = new CoreTypes(this.sys); this.cfn = cfn; this.basename = construct.classType.name; this.fqn = construct.fqn; this.attributes = this.findAttributeProperties(); this.physicalNameProp = this.findPhysicalNameProp(); } private findPhysicalNameProp() { if (!this.construct.propsType) { return undefined; } const resourceName = camelize(this.cfn.basename); // if resource name ends with "Name" (e.g. DomainName, then just use it as-is, otherwise append "Name") const physicalNameProp = resourceName.endsWith('Name') ? resourceName : `${resourceName}Name`; return this.construct.propsType.allProperties.find(x => x.name === physicalNameProp); } /** * Attribute properties are all the properties that begin with the type name (e.g. bucketXxx). */ private findAttributeProperties(): Attribute[] { const result = new Array<Attribute>(); for (const p of this.construct.classType.allProperties) { if (p.protected) { continue; // skip any protected properties } const basename = camelize(this.cfn.basename); // an attribute property is a property which starts with the type name // (e.g. "bucketXxx") and/or has an @attribute doc tag. const tag = getDocTag(p, 'attribute'); if (!p.name.startsWith(basename) && !tag) { continue; } let cfnAttributeNames; if (tag && tag !== 'true') { // if there's an `@attribute` doc tag with a value other than "true" // it should be used as the CFN attribute name instead of the property name // multiple attribute names can be listed as a comma-delimited list cfnAttributeNames = tag.split(','); } else { // okay, we don't have an explicit CFN attribute name, so we'll guess it // from the name of the property. const name = pascalize(p.name); if (this.cfn.attributeNames.includes(name)) { // special case: there is a cloudformation resource type in the attribute name // for example 'RoleId'. cfnAttributeNames = [name]; } else if (p.name.startsWith(basename)) { // begins with the resource name, just trim it cfnAttributeNames = [name.substring(this.cfn.basename.length)]; } else { // we couldn't determine CFN attribute name, so we don't account for this // as an attribute. this could be, for example, when a construct implements // an interface that represents another resource (e.g. `lambda.Alias` implements `IFunction`). continue; } } // check if this attribute is defined on an interface or on a class const property = findDeclarationSite(p); const site = property.parentType.isInterfaceType() ? AttributeSite.Interface : AttributeSite.Class; result.push({ site, cfnAttributeNames, property, }); } return result; } } function findDeclarationSite(prop: reflect.Property): reflect.Property { if (!prop.overrides || (!prop.overrides.isClassType() && !prop.overrides.isInterfaceType())) { if (!prop.parentType.isClassType() && !prop.parentType.isInterfaceType()) { throw new Error('invalid parent type'); } return prop; } const overridesProp = prop.overrides.allProperties.find(p => p.name === prop.name); if (!overridesProp) { throw new Error(`Cannot find property ${prop.name} in override site ${prop.overrides.fqn}`); } return findDeclarationSite(overridesProp); } resourceLinter.add({ code: 'resource-class-extends-resource', message: 'resource classes must extend "cdk.Resource" directly or indirectly', eval: e => { const resourceBase = e.ctx.sys.findClass(e.ctx.core.resourceClassFqn); e.assert(e.ctx.construct.classType.extends(resourceBase), e.ctx.construct.fqn); }, }); resourceLinter.add({ code: 'resource-interface', warning: true, message: 'every resource must have a resource interface', eval: e => { e.assert(e.ctx.construct.interfaceType, e.ctx.construct.fqn); }, }); resourceLinter.add({ code: 'resource-interface-extends-resource', message: 'construct interfaces of AWS resources must extend cdk.IResource', eval: e => { const resourceInterface = e.ctx.construct.interfaceType; if (!resourceInterface) { return; } const resourceInterfaceFqn = e.ctx.core.resourceInterfaceFqn; const interfaceBase = e.ctx.sys.findInterface(resourceInterfaceFqn); e.assert(resourceInterface.extends(interfaceBase), resourceInterface.fqn); }, }); /* // This rule is the worst resourceLinter.add({ code: 'resource-attribute', message: 'resources must represent all cloudformation attributes as attribute properties. ' + '"@attribute ATTR[,ATTR]" can be used to tag non-standard attribute names. ' + 'missing property:', eval: e => { for (const name of e.ctx.cfn.attributeNames) { const expected = camelcase(name).startsWith(camelcase(e.ctx.cfn.basename)) ? camelcase(name) : camelcase(e.ctx.cfn.basename + name); const found = e.ctx.attributes.find(a => a.cfnAttributeNames.includes(name)); e.assert(found, `${e.ctx.fqn}.${expected}`, expected); } }, }); */ resourceLinter.add({ code: 'grant-result', message: `"grant" method must return ${GRANT_RESULT_FQN}`, eval: e => { const grantResultType = e.ctx.sys.tryFindFqn(GRANT_RESULT_FQN); // this implies that we are at a lower layer (i.e. @aws-cdk/core) if (!grantResultType) { return; } const grantMethods = e.ctx.construct.classType.allMethods.filter(m => m.name.startsWith('grant')); for (const grantMethod of grantMethods) { e.assertSignature(grantMethod, { returns: grantResultType, }); } }, }); resourceLinter.add({ code: 'props-physical-name', message: 'Every Resource must have a single physical name construction property, ' + 'with a name that is an ending substring of <cfnResource>Name', eval: e => { if (!e.ctx.construct.propsType) { return; } e.assert(e.ctx.physicalNameProp, e.ctx.construct.propsFqn); }, }); resourceLinter.add({ code: 'props-physical-name-type', message: 'The type of the physical name prop should always be a "string"', eval: e => { if (!e.ctx.physicalNameProp) { return; } const prop = e.ctx.physicalNameProp; e.assertTypesEqual(e.ctx.sys, prop.type, 'string', `${e.ctx.construct.propsFqn}.${prop.name}`); }, }); function tryResolveCfnResource(resourceClass: reflect.ClassType): CfnResourceReflection | undefined { const sys = resourceClass.system; // if there is a @resource doc tag, it takes precedece const tag = resourceClass.docs.customTag('resource'); if (tag) { return CfnResourceReflection.findByName(sys, tag); } // parse the FQN of the class name and see if we can find a matching CFN resource const guess = guessResourceName(resourceClass.fqn); if (guess) { const cfn = CfnResourceReflection.findByName(sys, guess); if (cfn) { return cfn; } } // try to resolve through ancestors for (const base of resourceClass.ancestors) { const ret = tryResolveCfnResource(base); if (ret) { return ret; } } // failed misrably return undefined; } function guessResourceName(fqn: string) { // Strip any version suffixes e.g. 'TableV2' becomes 'Table' var match = /^(.+?)(V[0-9]+)?$/.exec(fqn); if (!match) { return undefined; } const [, versionless] = match; match = /aws-cdk-lib\.([a-z]+)_([a-z0-9]+)\.([A-Z][a-zA-Z0-9]+)/.exec(versionless); if (!match) { // Alpha name match = /@aws-cdk\/([a-z]+)-([a-z0-9]+)-alpha\.([A-Z][a-zA-Z0-9]+)/.exec(fqn); } if (!match) { return undefined; } const [, org, ns, rs] = match; if (!org || !ns || !rs) { return undefined; } return `${org}::${ns}::${rs}`; }