From 166a3365146d33f1e656f08b6de0a6e18d98b58c Mon Sep 17 00:00:00 2001 From: Mark DeCrane Date: Tue, 31 Oct 2023 08:41:06 -0400 Subject: [PATCH] hackathon Oct 2023 --- .../typescript/building-blocks/.gitignore | 11 + .../building-blocks/__tests__/main-test.ts | 87 ++++++ .../LambdaDBIamRolePolicyAttachment.ts | 131 +++++++++ .../typescript/building-blocks/cdktf.json | 19 ++ examples/typescript/building-blocks/help | 34 +++ .../typescript/building-blocks/jest.config.js | 16 + examples/typescript/building-blocks/main.ts | 99 +++++++ .../typescript/building-blocks/package.json | 32 ++ .../building-blocks/security-group-atom.ts | 275 ++++++++++++++++++ examples/typescript/building-blocks/setup.js | 7 + .../typescript/building-blocks/tsconfig.json | 33 +++ .../get/generator/emitter/resource-emitter.ts | 5 + packages/cdktf/lib/index.ts | 1 + .../terraform-building-block-generators.ts | 258 ++++++++++++++++ packages/cdktf/lib/terraform-resource.ts | 8 + packages/cdktf/package.json | 3 +- yarn.lock | 53 +++- 17 files changed, 1061 insertions(+), 11 deletions(-) create mode 100644 examples/typescript/building-blocks/.gitignore create mode 100644 examples/typescript/building-blocks/__tests__/main-test.ts create mode 100644 examples/typescript/building-blocks/building-blocks/LambdaDBIamRolePolicyAttachment.ts create mode 100644 examples/typescript/building-blocks/cdktf.json create mode 100644 examples/typescript/building-blocks/help create mode 100644 examples/typescript/building-blocks/jest.config.js create mode 100644 examples/typescript/building-blocks/main.ts create mode 100644 examples/typescript/building-blocks/package.json create mode 100644 examples/typescript/building-blocks/security-group-atom.ts create mode 100644 examples/typescript/building-blocks/setup.js create mode 100644 examples/typescript/building-blocks/tsconfig.json create mode 100644 packages/cdktf/lib/terraform-building-block-generators.ts diff --git a/examples/typescript/building-blocks/.gitignore b/examples/typescript/building-blocks/.gitignore new file mode 100644 index 0000000000..1dfae30c78 --- /dev/null +++ b/examples/typescript/building-blocks/.gitignore @@ -0,0 +1,11 @@ +*.d.ts +*.js +node_modules +cdktf.out +cdktf.log +*terraform.*.tfstate* +.gen +.terraform +tsconfig.tsbuildinfo +!jest.config.js +!setup.js \ No newline at end of file diff --git a/examples/typescript/building-blocks/__tests__/main-test.ts b/examples/typescript/building-blocks/__tests__/main-test.ts new file mode 100644 index 0000000000..4f4cd20106 --- /dev/null +++ b/examples/typescript/building-blocks/__tests__/main-test.ts @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// import { Testing } from "cdktf"; +// import "cdktf/lib/testing/adapters/jest"; + +describe("My CDKTF Application", () => { + it.todo("should be tested"); + + // // All Unit testst test the synthesised terraform code, it does not create real-world resources + // describe("Unit testing using assertions", () => { + // it("should contain a resource", () => { + // // import { Image,Container } from "./.gen/providers/docker" + // expect( + // Testing.synthScope((scope) => { + // new MyApplicationsAbstraction(scope, "my-app", {}); + // }) + // ).toHaveResource(Container); + + // expect( + // Testing.synthScope((scope) => { + // new MyApplicationsAbstraction(scope, "my-app", {}); + // }) + // ).toHaveResourceWithProperties(Image, { name: "ubuntu:latest" }); + // }); + // }); + + // describe("Unit testing using snapshots", () => { + // it("Tests the snapshot", () => { + // const app = Testing.app(); + // const stack = new TerraformStack(app, "test"); + + // new TestProvider(stack, "provider", { + // accessKey: "1", + // }); + + // new TestResource(stack, "test", { + // name: "my-resource", + // }); + + // expect(Testing.synth(stack)).toMatchSnapshot(); + // }); + + // it("Tests a combination of resources", () => { + // expect( + // Testing.synthScope((stack) => { + // new TestDataSource(stack, "test-data-source", { + // name: "foo", + // }); + + // new TestResource(stack, "test-resource", { + // name: "bar", + // }); + // }) + // ).toMatchInlineSnapshot(); + // }); + // }); + + // describe("Checking validity", () => { + // it("check if the produced terraform configuration is valid", () => { + // const app = Testing.app(); + // const stack = new TerraformStack(app, "test"); + + // new TestDataSource(stack, "test-data-source", { + // name: "foo", + // }); + + // new TestResource(stack, "test-resource", { + // name: "bar", + // }); + // expect(Testing.fullSynth(app)).toBeValidTerraform(); + // }); + + // it("check if this can be planned", () => { + // const app = Testing.app(); + // const stack = new TerraformStack(app, "test"); + + // new TestDataSource(stack, "test-data-source", { + // name: "foo", + // }); + + // new TestResource(stack, "test-resource", { + // name: "bar", + // }); + // expect(Testing.fullSynth(app)).toPlanSuccessfully(); + // }); + // }); +}); diff --git a/examples/typescript/building-blocks/building-blocks/LambdaDBIamRolePolicyAttachment.ts b/examples/typescript/building-blocks/building-blocks/LambdaDBIamRolePolicyAttachment.ts new file mode 100644 index 0000000000..2aaa3df9fc --- /dev/null +++ b/examples/typescript/building-blocks/building-blocks/LambdaDBIamRolePolicyAttachment.ts @@ -0,0 +1,131 @@ +import { Construct } from "constructs"; +import { + IamRolePolicyAttachment, + IamRolePolicyAttachmentConfig, +} from "../.gen/providers/aws/iam-role-policy-attachment"; + +import { IamRole } from "../.gen/providers/aws/iam-role"; + +/** + * A Building Block for IamRolePolicyAttachment + * @defaults Uses the following defaults: + * { + * "policyArn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + * } + */ +export class LambdaDBIamRolePolicyAttachment { + public defaultValues = { + policyArn: + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + }; + public valueExtractorMap = { + role: (role: IamRole | any) => { + return role.name; + }, + }; + constructor( + scope: Construct, + id: string, + config: TypedIamRolePolicyAttachmentConfig + ) { + const resourceConfig = this._convertConfig(config); + + new IamRolePolicyAttachment( + scope, + id, + resourceConfig as IamRolePolicyAttachmentConfig + ); + } + + private _deepMerge(newConfig: any, defaultConfig: any): any { + if (!newConfig && !defaultConfig) { + return; + } + if (Array.isArray(newConfig) && Array.isArray(defaultConfig)) { + return [ + { + ...newConfig[0], + ...defaultConfig[0], + }, + this._deepMerge(newConfig.shift(), defaultConfig.shift()), + ]; + } else { + return { + ...newConfig, + ...defaultConfig, + }; + } + } + + private _convertConfig(typedConfig: any) { + const userSpecifiedConfig = + this._userSpecifiedAttributesConvert(typedConfig); + const newConfig = this._populateDefaultValues(userSpecifiedConfig); + return newConfig; + } + + private _userSpecifiedAttributesConvert(typedConfig: any) { + if (!typedConfig) { + return; + } + type NewConfig = { [key: string]: any }; + let newConfig: NewConfig = {}; + for (const key in typedConfig) { + type TypedConfigKey = keyof typeof typedConfig; + const typeConfigKey = key as TypedConfigKey; + if (key in this.valueExtractorMap) { + type ValueExtractorKey = keyof typeof this.valueExtractorMap; + const valueExtractorKey = key as ValueExtractorKey; + newConfig[key] = this.valueExtractorMap[valueExtractorKey]( + typedConfig[typeConfigKey] + ); + } + // need to deal with the case that its an array as well + else if (typeof typedConfig[key] === "object") { + if (Array.isArray(typedConfig[key])) { + const attributeArray = []; + for (const attribute of typedConfig[key]) { + attributeArray.push( + this._userSpecifiedAttributesConvert(attribute) + ); + } + newConfig[key] = attributeArray; + } else { + newConfig[key] = this._userSpecifiedAttributesConvert( + typedConfig[key] + ); + } + } else { + newConfig[key] = typedConfig[key]; + } + } + return newConfig; + } + // still isn't working correctly, first element of array is repeated + private _populateDefaultValues(userSpecifiedConfig: any) { + type NewConfig = { [key: string]: any }; + let newConfig: NewConfig = userSpecifiedConfig; + for (const key in this.defaultValues) { + type DefaultValueKey = keyof typeof this.defaultValues; + const defaultValueKey = key as DefaultValueKey; + if (!newConfig.hasOwnProperty(key)) { + newConfig[key] = this.defaultValues[defaultValueKey]; + } else if ( + newConfig.hasOwnProperty(key) && + typeof newConfig[key] === "object" + ) { + newConfig[key] = this._deepMerge( + newConfig[key], + this.defaultValues[defaultValueKey] + ); + } + } + return newConfig; + } +} + +export interface TypedIamRolePolicyAttachmentConfig { + id?: string | undefined; + policyArn?: string; + role: IamRole; +} diff --git a/examples/typescript/building-blocks/cdktf.json b/examples/typescript/building-blocks/cdktf.json new file mode 100644 index 0000000000..842229bc20 --- /dev/null +++ b/examples/typescript/building-blocks/cdktf.json @@ -0,0 +1,19 @@ +{ + "language": "typescript", + "app": "npx ts-node main.ts", + "terraformProviders": [ + "aws@~> 5.0" + ], + "terraformModules": [ + { + "name": "aurora", + "source": "terraform-aws-modules/rds-aurora/aws", + "version": "~> 8.0" + }, + { + "name": "vpc", + "source": "terraform-aws-modules/vpc/aws", + "version": "~> 5.1.1" + } + ] +} \ No newline at end of file diff --git a/examples/typescript/building-blocks/help b/examples/typescript/building-blocks/help new file mode 100644 index 0000000000..83a74dd683 --- /dev/null +++ b/examples/typescript/building-blocks/help @@ -0,0 +1,34 @@ +======================================================================================================== + + Your cdktf typescript project is ready! + + cat help Print this message + + Compile: + npm run compile Compile typescript code to javascript (or "yarn watch") + npm run watch Watch for changes and compile typescript in the background + npm run build cdktf get and compile typescript + + Synthesize: + cdktf synth Synthesize Terraform resources from stacks to cdktf.out/ (ready for 'terraform apply') + + Diff: + cdktf diff Perform a diff (terraform plan) for the given stack + + Deploy: + cdktf deploy Deploy the given stack + + Destroy: + cdktf destroy Destroy the stack + + Test: + npm run test Runs unit tests (edit __tests__/main-test.ts to add your own tests) + npm run test:watch Watches the tests and reruns them on change + + + Upgrades: + npm run get Import/update Terraform providers and modules (you should check-in this directory) + npm run upgrade Upgrade cdktf modules to latest version + npm run upgrade:next Upgrade cdktf modules to latest "@next" version (last commit) + +======================================================================================================== diff --git a/examples/typescript/building-blocks/jest.config.js b/examples/typescript/building-blocks/jest.config.js new file mode 100644 index 0000000000..e53ea43b7d --- /dev/null +++ b/examples/typescript/building-blocks/jest.config.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +module.exports = { + clearMocks: true, + coverageProvider: "v8", + setupFilesAfterEnv: ["./setup.js"], + }; + diff --git a/examples/typescript/building-blocks/main.ts b/examples/typescript/building-blocks/main.ts new file mode 100644 index 0000000000..904f1561b2 --- /dev/null +++ b/examples/typescript/building-blocks/main.ts @@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Construct } from "constructs"; +import { App, TerraformStack, TerraformAtomGenerator } from "cdktf"; +import { AwsProvider } from "./.gen/providers/aws/provider"; +import { IamRole } from "./.gen/providers/aws/iam-role"; +import { IamRolePolicyAttachment } from "./.gen/providers/aws/iam-role-policy-attachment"; +import { LambdaDBIamRolePolicyAttachment } from "./building-blocks/LambdaDBIamRolePolicyAttachment"; + +class GeneratorStack extends TerraformStack { + constructor(scope: Construct, ns: string) { + super(scope, ns); + + new TerraformAtomGenerator({ + resource: IamRolePolicyAttachment, + className: "LambdaDBIamRolePolicyAttachment", + workingDir: __dirname, + typedAttributes: [ + { + name: "role", + type: IamRole, + function: "(role: IamRole | any) => { return role.name }", + }, + ], + imports: [ + { + name: "IamRole", + path: ".gen/providers/aws/iam-role", + }, + ], + defaults: { + policyArn: + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + }, + }); + } +} + +class MyStack extends TerraformStack { + constructor(scope: Construct, ns: string) { + super(scope, ns); + + new AwsProvider(this, "aws", { + region: "us-east-1", + }); + + const role = new IamRole(this, "lambda-exec", { + name: `iam-role`, + assumeRolePolicy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Principal: { + Service: "lambda.amazonaws.com", + }, + Effect: "Allow", + Sid: "", + }, + ], + }), + inlinePolicy: [ + { + name: "AllowDynamoDB", + policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Action: [ + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:BatchGetItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + ], + Resource: "table arn here", + Effect: "Allow", + }, + ], + }), + }, + ], + }); + + new LambdaDBIamRolePolicyAttachment( + this, + "lambda-db-iam-policy-attachment", + { + id: "policy-attachment", + role: role, + } + ); + } +} + +const app = new App(); +new GeneratorStack(app, "generators"); +new MyStack(app, "ts-import"); +app.synth(); diff --git a/examples/typescript/building-blocks/package.json b/examples/typescript/building-blocks/package.json new file mode 100644 index 0000000000..7dd0eddb6d --- /dev/null +++ b/examples/typescript/building-blocks/package.json @@ -0,0 +1,32 @@ +{ + "//": "This example test is disabled via the 'private' attribute since Terraform 1.5 is required for proper synth, and as of writing CI has a lesser version", + "name": "@examples/typescript-building-blocks", + "private": true, + "version": "0.0.0", + "main": "main.js", + "types": "main.ts", + "license": "MPL-2.0", + "scripts": { + "get": "cdktf get", + "build": "yarn get && tsc", + "synth": "cdktf synth", + "compile": "tsc --pretty", + "watch": "tsc -w", + "test": "jest", + "test:watch": "jest --watch", + "upgrade": "npm i cdktf@latest cdktf-cli@latest", + "upgrade:next": "npm i cdktf@next cdktf-cli@next" + }, + "dependencies": { + "cdktf": "0.0.0", + "constructs": "^10.0.25" + }, + "devDependencies": { + "@types/jest": "27.5.2", + "@types/node": "18.11.19", + "cdktf-cli": "0.0.0", + "jest": "^27.5.1", + "ts-node": "^10.9.1", + "typescript": "^5.0.2" + } +} \ No newline at end of file diff --git a/examples/typescript/building-blocks/security-group-atom.ts b/examples/typescript/building-blocks/security-group-atom.ts new file mode 100644 index 0000000000..8c3063f18e --- /dev/null +++ b/examples/typescript/building-blocks/security-group-atom.ts @@ -0,0 +1,275 @@ +import { Construct } from "constructs"; +import { + SecurityGroup, + SecurityGroupConfig, + SecurityGroupTimeouts, +} from "./.gen/providers/aws/security-group"; +import { Vpc } from "./.gen/modules/vpc"; +import { Token } from "cdktf"; + +export interface TypedSecurityGroupIngress { + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#cidr_blocks SecurityGroup#cidr_blocks} + */ + readonly cidrBlocks?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#description SecurityGroup#description} + */ + readonly description?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#from_port SecurityGroup#from_port} + */ + readonly fromPort?: number; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#ipv6_cidr_blocks SecurityGroup#ipv6_cidr_blocks} + */ + readonly ipv6CidrBlocks?: Vpc; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#prefix_list_ids SecurityGroup#prefix_list_ids} + */ + readonly prefixListIds?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#protocol SecurityGroup#protocol} + */ + readonly protocol?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#security_groups SecurityGroup#security_groups} + */ + readonly securityGroups?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#self SecurityGroup#self} + */ + readonly selfAttribute?: boolean; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#to_port SecurityGroup#to_port} + */ + readonly toPort?: number; +} +export interface TypedSecurityGroupEgress { + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#cidr_blocks SecurityGroup#cidr_blocks} + */ + readonly cidrBlocks?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#description SecurityGroup#description} + */ + readonly description?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#from_port SecurityGroup#from_port} + */ + readonly fromPort?: number; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#ipv6_cidr_blocks SecurityGroup#ipv6_cidr_blocks} + */ + readonly ipv6CidrBlocks?: Vpc; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#prefix_list_ids SecurityGroup#prefix_list_ids} + */ + readonly prefixListIds?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#protocol SecurityGroup#protocol} + */ + readonly protocol?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#security_groups SecurityGroup#security_groups} + */ + readonly securityGroups?: string[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#self SecurityGroup#self} + */ + readonly selfAttribute?: boolean; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#to_port SecurityGroup#to_port} + */ + readonly toPort?: number; +} +export interface TypedSecurityGroupConfig { + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#description SecurityGroup#description} + */ + readonly description?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#egress SecurityGroup#egress} + */ + readonly egress?: TypedSecurityGroupEgress[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#id SecurityGroup#id} + * + * Please be aware that the id field is automatically added to all resources in Terraform providers using a Terraform provider SDK version below 2. + * If you experience problems setting this value it might not be settable. Please take a look at the provider documentation to ensure it should be settable. + */ + readonly id?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#ingress SecurityGroup#ingress} + */ + readonly ingress?: TypedSecurityGroupIngress[]; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#name SecurityGroup#name} + */ + readonly name?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#name_prefix SecurityGroup#name_prefix} + */ + readonly namePrefix?: string; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#revoke_rules_on_delete SecurityGroup#revoke_rules_on_delete} + */ + readonly revokeRulesOnDelete?: boolean; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#tags SecurityGroup#tags} + */ + readonly tags?: { [key: string]: string }; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#tags_all SecurityGroup#tags_all} + */ + readonly tagsAll?: { [key: string]: string }; + /** + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#vpc_id SecurityGroup#vpc_id} + */ + readonly vpcId?: Vpc; + /** + * timeouts block + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/aws/5.22.0/docs/resources/security_group#timeouts SecurityGroup#timeouts} + */ + readonly timeouts?: SecurityGroupTimeouts; +} + +const valueExtractorMap = { + vpcId: (vpc: Vpc | any) => { + return Token.asString(vpc.vpcIdOutput); + }, + ipv6CidrBlocks: (vpc: Vpc | any) => { + return [vpc.vpcCidrBlockOutput]; + }, +}; + +export class TerraformSecurityGroupAtom { + public defaultValues = { + name: `access-db`, + description: "Allows to access the DB", + ingress: [ + { + fromPort: 5432, + toPort: 5432, + protocol: "tcp", + description: "Postgres access from within the VPC", + }, + { + fromPort: 1024, + toPort: 65535, + protocol: "tcp", + description: "S3 ephemeral port traffic back to lambda in VPC", + }, + ], + egress: [ + { + fromPort: 5432, + toPort: 5432, + protocol: "tcp", + description: "Postgres access from within the VPC", + }, + { + fromPort: 443, + toPort: 443, + protocol: "tcp", + ipv6CidrBlocks: ["0.0.0.0/0"], + }, + ], + }; + constructor(scope: Construct, id: string, config: TypedSecurityGroupConfig) { + const resourceConfig = this._convertConfig(config); + + SecurityGroup.createResource( + scope, + id, + resourceConfig as SecurityGroupConfig + ); + } + + private _deepMerge(newConfig: any, defaultConfig: any): any { + if (!newConfig && !defaultConfig) { + return; + } + if (Array.isArray(newConfig) && Array.isArray(defaultConfig)) { + return [ + { + ...newConfig[0], + ...defaultConfig[0], + }, + this._deepMerge(newConfig.shift(), defaultConfig.shift()), + ]; + } else { + return { + ...newConfig, + ...defaultConfig, + }; + } + } + + private _convertConfig(typedConfig: any) { + const userSpecifiedConfig = + this._userSpecifiedAttributesConvert(typedConfig); + const newConfig = this._populateDefaultValues(userSpecifiedConfig); + return newConfig; + } + + private _userSpecifiedAttributesConvert(typedConfig: any) { + if (!typedConfig) { + return; + } + type NewConfig = { [key: string]: any }; + let newConfig: NewConfig = {}; + for (const key in typedConfig) { + type TypedConfigKey = keyof typeof typedConfig; + const typeConfigKey = key as TypedConfigKey; + if (key in valueExtractorMap) { + type ValueExtractorKey = keyof typeof valueExtractorMap; + const valueExtractorKey = key as ValueExtractorKey; + newConfig[key] = valueExtractorMap[valueExtractorKey]( + typedConfig[typeConfigKey] + ); + } + // need to deal with the case that its an array as well + else if (typeof typedConfig[key] === "object") { + if (Array.isArray(typedConfig[key])) { + const attributeArray = []; + for (const attribute of typedConfig[key]) { + attributeArray.push( + this._userSpecifiedAttributesConvert(attribute) + ); + } + newConfig[key] = attributeArray; + } else { + newConfig[key] = this._userSpecifiedAttributesConvert( + typedConfig[key] + ); + } + } else { + newConfig[key] = typedConfig[key]; + } + } + return newConfig; + } + // still isn't working correctly, first element of array is repeated + private _populateDefaultValues(userSpecifiedConfig: any) { + type NewConfig = { [key: string]: any }; + let newConfig: NewConfig = userSpecifiedConfig; + for (const key in this.defaultValues) { + type DefaultValueKey = keyof typeof this.defaultValues; + const defaultValueKey = key as DefaultValueKey; + if (!newConfig.hasOwnProperty(key)) { + newConfig[key] = this.defaultValues[defaultValueKey]; + } else if ( + newConfig.hasOwnProperty(key) && + typeof newConfig[key] === "object" + ) { + newConfig[key] = this._deepMerge( + newConfig[key], + this.defaultValues[defaultValueKey] + ); + } + } + return newConfig; + } +} diff --git a/examples/typescript/building-blocks/setup.js b/examples/typescript/building-blocks/setup.js new file mode 100644 index 0000000000..cabf53825f --- /dev/null +++ b/examples/typescript/building-blocks/setup.js @@ -0,0 +1,7 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +const cdktf = require("cdktf"); +cdktf.Testing.setupJest(); diff --git a/examples/typescript/building-blocks/tsconfig.json b/examples/typescript/building-blocks/tsconfig.json new file mode 100644 index 0000000000..778b2e42e3 --- /dev/null +++ b/examples/typescript/building-blocks/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "declaration": true, + "experimentalDecorators": true, + "inlineSourceMap": true, + "inlineSources": true, + "lib": [ + "es2018" + ], + "module": "CommonJS", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2018", + "incremental": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/@cdktf/provider-generator/lib/get/generator/emitter/resource-emitter.ts b/packages/@cdktf/provider-generator/lib/get/generator/emitter/resource-emitter.ts index 506eb69f4b..5b380f03d8 100644 --- a/packages/@cdktf/provider-generator/lib/get/generator/emitter/resource-emitter.ts +++ b/packages/@cdktf/provider-generator/lib/get/generator/emitter/resource-emitter.ts @@ -76,6 +76,11 @@ export class ResourceEmitter { return new cdktf.ImportableResource(scope, importToId, { terraformResourceType: "${resource.terraformResourceType}", importId: importFromId, provider }); }` ); + this.code.line( + `public static createResource(scope: Construct, id: string, config: ${resource.configStruct.attributeType}) { + return new this(scope, id, config); + }` + ); } private emitResourceSynthesis(resource: ResourceModel) { diff --git a/packages/cdktf/lib/index.ts b/packages/cdktf/lib/index.ts index f038b48dc6..bbb72e7df8 100644 --- a/packages/cdktf/lib/index.ts +++ b/packages/cdktf/lib/index.ts @@ -39,6 +39,7 @@ export * from "./terraform-conditions"; export * from "./terraform-count"; export * from "./importable-resource"; export * from "./terraform-resource-targets"; +export * from "./terraform-building-block-generators"; // required for JSII because Fn extends from it export * from "./functions/terraform-functions.generated"; diff --git a/packages/cdktf/lib/terraform-building-block-generators.ts b/packages/cdktf/lib/terraform-building-block-generators.ts new file mode 100644 index 0000000000..b6365af083 --- /dev/null +++ b/packages/cdktf/lib/terraform-building-block-generators.ts @@ -0,0 +1,258 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Project } from "ts-morph"; + +const privateFunctions = ` +private _deepMerge(newConfig: any, defaultConfig: any): any { + if (!newConfig && !defaultConfig) { + return + } + if (Array.isArray(newConfig) && Array.isArray(defaultConfig)) { + return [ + { + ...newConfig[0], + ...defaultConfig[0] + }, + this._deepMerge(newConfig.shift(), defaultConfig.shift()) + ] + } else { + return { + ...newConfig, + ...defaultConfig + } + } +} + +private _convertConfig(typedConfig: any) { + const userSpecifiedConfig = this._userSpecifiedAttributesConvert(typedConfig); + const newConfig = this._populateDefaultValues(userSpecifiedConfig) + return newConfig +} + +private _userSpecifiedAttributesConvert(typedConfig: any) { + if (!typedConfig) { + return + } + type NewConfig = { [key: string]: any } + let newConfig: NewConfig = {} + for (const key in typedConfig) { + type TypedConfigKey = keyof typeof typedConfig; + const typeConfigKey = key as TypedConfigKey; + if (key in this.valueExtractorMap) { + type ValueExtractorKey = keyof typeof this.valueExtractorMap; + const valueExtractorKey = key as ValueExtractorKey; + newConfig[key] = this.valueExtractorMap[valueExtractorKey](typedConfig[typeConfigKey]) + } + // need to deal with the case that its an array as well + else if (typeof typedConfig[key] === "object") { + if (Array.isArray(typedConfig[key])) { + const attributeArray = [] + for (const attribute of typedConfig[key]) { + attributeArray.push(this._userSpecifiedAttributesConvert(attribute)) + } + newConfig[key] = attributeArray; + } else { + newConfig[key] = this._userSpecifiedAttributesConvert(typedConfig[key]) + } + } else { + newConfig[key] = typedConfig[key] + } + } + return newConfig; +} +// still isn't working correctly, first element of array is repeated +private _populateDefaultValues(userSpecifiedConfig: any) { + type NewConfig = { [key: string]: any } + let newConfig: NewConfig = userSpecifiedConfig; + for (const key in this.defaultValues) { + type DefaultValueKey = keyof typeof this.defaultValues; + const defaultValueKey = key as DefaultValueKey; + if (!newConfig.hasOwnProperty(key)) { + newConfig[key] = this.defaultValues[defaultValueKey] + } + else if (newConfig.hasOwnProperty(key) && typeof newConfig[key] === "object") { + newConfig[key] = this._deepMerge(newConfig[key], this.defaultValues[defaultValueKey]) + } + } + return newConfig; +} +`; + +export interface TerraformConstructor { + readonly tfResourceType: string; +} + +export interface TypedAttributeDoc { + description: string; + tags?: string[]; +} + +export interface TypedAttribute { + name: string; + type: TerraformConstructor; + function: string; + initializer?: any; + hasQuestionToken?: boolean; + isReadonly?: boolean; + docs?: TypedAttributeDoc[]; +} + +export interface TerraformAtomGeneratorConfig { + imports?: [ + { + name: string; + path: string; + } + ]; + defaults?: any; + typedAttributes?: TypedAttribute[]; + resource: TerraformConstructor; + className: string; + workingDir: string; +} +/** + * + * @param resource + */ +function resourceTypeToClassName(resource: TerraformConstructor) { + const pieces = resource.tfResourceType.split("_"); + pieces.shift(); + const className = pieces.reduce((accum, curr) => { + return accum + curr.charAt(0).toUpperCase() + curr.slice(1); + }, ""); + return className; +} +/** + * + * @param resource + */ +function resourceTypeToFilePath(resource: TerraformConstructor) { + const pieces = resource.tfResourceType.split("_"); + const providerName = pieces[0]; + pieces.shift(); + const first = pieces[0]; + pieces.shift(); + const folderName = pieces.reduce((accum, curr) => { + return accum + "-" + curr; + }, first); + return `.gen/providers/${providerName}/${folderName}`; +} + +/** + * + */ +export class TerraformAtomGenerator { + constructor(config: TerraformAtomGeneratorConfig) { + const project = new Project({ + tsConfigFilePath: `${config.workingDir}/tsconfig.json`, + libFolderPath: `${config.workingDir}`, + }); + + //project.addSourceFilesAtPaths(`${config.pathToResourceFile}/*{.d.ts,.ts}`) + const resourceClassName = resourceTypeToClassName(config.resource); + const resourceFilePath = resourceTypeToFilePath(config.resource); + const resourceSourceFile = project.getSourceFile( + `${resourceFilePath}/index.ts` + ); + if (!resourceSourceFile) { + throw new Error( + `wrong path to resource at: ${resourceFilePath}/index.ts` + ); + } + const resourceInterface = resourceSourceFile.getInterfaceOrThrow( + `${resourceClassName}Config` + ); + + const interfaceName = resourceInterface.getName(); + const interfaceProperties = resourceInterface.getProperties(); + + const newTypedAttributes = config.typedAttributes + ? config.typedAttributes.map((attribute) => { + return attribute.name; + }) + : []; + const attributes = []; + for (const property of interfaceProperties) { + if (!newTypedAttributes.includes(property.getName())) { + attributes.push({ + name: property.getName(), + type: property.getType().getText(), + hasQuestionToken: property.hasQuestionToken(), + }); + } + } + const functions = []; + if (config.typedAttributes) { + for (const property of config.typedAttributes) { + const name = property.name; + attributes.push({ + name: name, + type: resourceTypeToClassName(property.type), + }); + functions.push({ name: name, function: property.function }); + } + } + + const valueExtractorMap = functions.reduce((accum, curr) => { + return `${accum}\n${curr.name}:${curr.function}`; + }, ""); + + const importsArray = []; + if (config.imports) { + for (const importOf of config.imports) { + importsArray.push( + `import { ${importOf.name} } from "../${importOf.path}"` + ); + } + } + const importsString = importsArray.reduce((accum, curr) => { + return `${accum}\n${curr}`; + }, ""); + + const buildingBlockFile = project.createSourceFile( + `${config.workingDir}/building-blocks/${config.className}.ts`, + ` + import { Construct } from "constructs"; + import { ${resourceClassName}, ${interfaceName}} from "../${resourceFilePath}" + ${importsString} + + export class ${config.className} { + public defaultValues = ${JSON.stringify(config.defaults)} + public valueExtractorMap = { + ${valueExtractorMap} + } + constructor(scope: Construct, id: string, config: Typed${interfaceName}) { + + const resourceConfig = this._convertConfig(config) + + new ${resourceClassName}(scope, id, resourceConfig as ${interfaceName}) + } + + ${privateFunctions} + } + `, + { overwrite: true } + ); + + buildingBlockFile.getClass(config.className)?.addJsDoc({ + description: `A Building Block for ${resourceClassName}`, + tags: [ + { + tagName: "defaults", + text: `Uses the following defaults: \n ${JSON.stringify( + config.defaults, + null, + 2 + )}`, + }, + ], + }); + + buildingBlockFile.addInterface({ + name: `Typed${interfaceName}`, + isExported: true, + properties: attributes, + }); + project.saveSync(); + } +} diff --git a/packages/cdktf/lib/terraform-resource.ts b/packages/cdktf/lib/terraform-resource.ts index 6a2e543a7c..3f9c7c9cb1 100644 --- a/packages/cdktf/lib/terraform-resource.ts +++ b/packages/cdktf/lib/terraform-resource.ts @@ -273,6 +273,14 @@ export class TerraformResource ); } + public static resourceFactory( + scope: Construct, + id: string, + config: TerraformResourceConfig + ) { + return new this(scope, id, config); + } + public importFrom(id: string, provider?: TerraformProvider) { this._imported = { id, provider }; this.node.addValidation( diff --git a/packages/cdktf/package.json b/packages/cdktf/package.json index a271f49673..3d74644a18 100644 --- a/packages/cdktf/package.json +++ b/packages/cdktf/package.json @@ -125,7 +125,8 @@ "dependencies": { "archiver": "5.3.1", "json-stable-stringify": "^1.0.2", - "semver": "^7.5.3" + "semver": "^7.5.3", + "ts-morph": "^20.0.0" }, "bundledDependencies": [ "archiver", diff --git a/yarn.lock b/yarn.lock index 1e969ff030..a5aaa7422e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2052,6 +2052,16 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@ts-morph/common@~0.21.0": + version "0.21.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.21.0.tgz#30272bde654127326d8b73643b9a8de280135fb4" + integrity sha512-ES110Mmne5Vi4ypUKrtVQfXFDtCsDXiUiGxF6ILVlE90dDD4fdpC1LSjydl/ml7xJWKSDZwUYD2zkOePMSrPBA== + dependencies: + fast-glob "^3.2.12" + minimatch "^7.4.3" + mkdirp "^2.1.6" + path-browserify "^1.0.1" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -2327,7 +2337,7 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*": +"@types/node@*", "@types/node@16.18.23": version "16.18.23" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.23.tgz#b6e934fe427eb7081d0015aad070acb3373c3c90" integrity sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g== @@ -3601,6 +3611,11 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +code-block-writer@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-12.0.0.tgz#4dd58946eb4234105aff7f0035977b2afdc2a770" + integrity sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w== + code-excerpt@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-3.0.0.tgz#fcfb6748c03dba8431c19f5474747fad3f250f10" @@ -5127,10 +5142,10 @@ fast-glob@3.2.7: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== +fast-glob@^3.2.12, fast-glob@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" + integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -5138,10 +5153,10 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" - integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== +fast-glob@^3.2.9: + version "3.2.12" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" + integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -8707,7 +8722,7 @@ minimatch@^6.1.6: dependencies: brace-expansion "^2.0.1" -minimatch@^7.4.2: +minimatch@^7.4.2, minimatch@^7.4.3: version "7.4.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.6.tgz#845d6f254d8f4a5e4fd6baf44d5f10c8448365fb" integrity sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw== @@ -8864,6 +8879,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mkdirp@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" + integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -9757,6 +9777,11 @@ patch-console@^1.0.0: resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-1.0.0.tgz#19b9f028713feb8a3c023702a8cc8cb9f7466f9d" integrity sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA== +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -11489,6 +11514,14 @@ ts-jest@^29.1.1: semver "^7.5.3" yargs-parser "^21.0.1" +ts-morph@^20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-20.0.0.tgz#c46b4c231dfc93347091901f1f9a3e13413230fd" + integrity sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg== + dependencies: + "@ts-morph/common" "~0.21.0" + code-block-writer "^12.0.0" + ts-node@^10.9.1: version "10.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b"