diff --git a/examples/api.ts b/examples/api.ts new file mode 100644 index 0000000..a47a8ab --- /dev/null +++ b/examples/api.ts @@ -0,0 +1,215 @@ +import { modulate, config, Provider, Terraform } from '../src/config' +import { AWS05200 as AWS } from '../registry/index' + +import { rout53_zone, acm_cert, route53_record, acm_validation } from './route53' + +// ,e, +// /~~~8e 888-~88e " +// 88b 888 888b 888 +// e88~-888 888 8888 888 +// C888 888 888 888P 888 +// "88_-888 888-_88" 888 +// 888 +const api_domain = ({ subdomain = 'api', apex = 'chopshop-test.net', cert_arn }): AWS => ({ + resource: { + apigatewayv2_domain_name: { + domain_name: `${subdomain}.${apex}`, + /** + * Block type "domain_name_configuration" is represented by a list + * of objects, so it must be indexed using a numeric key, like + * .domain_name_configuration[0] + */ + // @ts-ignore + domain_name_configuration: [ + { + certificate_arn: cert_arn, + endpoint_type: 'REGIONAL', + security_policy: 'TLS_1_2', + target_domain_name: '-->', + hosted_zone_id: '-->', + }, + ], + tags: { + Name: `${subdomain}.${apex}`, + BroughtToYouBy: '@-0/micro', + }, + }, + }, +}) + +const api_gateway = ({ name }): AWS => ({ + resource: { + apigatewayv2_api: { + name, + description: `api for ${name}`, + disable_execute_api_endpoint: false, + protocol_type: 'HTTP', + cors_configuration: { + allow_headers: [ + 'content-type', + 'x-amz-date', + 'authorization', + 'x-api-key', + 'x-amz-security-token', + 'x-amz-user-agent', + ], + allow_methods: ['*'], + allow_origins: ['*'], + max_age: 300, + }, + api_endpoint: '-->', + execution_arn: '-->', + id: '-->', + }, + }, +}) + +const api_stage = ({ api_id, name = '$default' }): AWS => ({ + resource: { + apigatewayv2_stage: { + api_id, + name, + auto_deploy: true, + description: `stage ${name} API`, + }, + }, +}) + +const api_lambda_integration = ({ api_id, lambda_invoke_arn }): AWS => ({ + resource: { + // @ts-ignore: šŸ› [3] + apigatewayv2_integration: { + api_id, + integration_uri: lambda_invoke_arn, + integration_type: 'AWS_PROXY', + integration_method: 'POST', + connection_type: 'INTERNET', + payload_format_version: '2.0', + timeout_milliseconds: 29000, // 30 sec max for HTTP, 29 for WebSockets + id: '-->', + }, + }, +}) + +const api_route = ({ api_id, route_key = 'ANY /', integration_id }): AWS => ({ + resource: { + // @ts-ignore: šŸ› [2] + apigatewayv2_route: { + api_id, + route_key, + target: `integrations/${integration_id}`, + id: '-->', + + //authorization_scopes: 'TODO', + //authorization_type: 'TODO', + //authorizer_id: 'TODO' + }, + }, +}) + +interface Subdomains { + [key: string]: { + [key: string]: { + invoke_arn: string + } + } +} +// 888 888 /~~88b +// 888-~88e-~88e e88~-_ e88~\888 888 888 888 e88~~8e | 888 +// 888 888 888 d888 i d888 888 888 888 888 d888 88b ` d88P +// 888 888 888 8888 | 8888 888 888 888 888 8888__888 d88P +// 888 888 888 Y888 ' Y888 888 888 888 888 Y888 , d88P +// 888 888 888 "88_-~ "88_/888 "88_-888 888 "88___/ d88P___ + +/** + * subdomains module + * + * @param apex - apex domain name + * @param subdomains - array of subdomains + * - name - name of the subdomain + * - lambda_integration - lambda integration object + * - lambda_invoke_arn - arn of the lambda function to integrate + * - routes - array of routes + * - route object + * - route_key - route key + * - integration_id - id of the integration to use + * @param my - self reference for referencing other resources + * + */ +export const subdomains = ( + { + apex = 'chopshop-test.net', + subdomainRoutes = { + test: { + 'ANY /': { + invoke_arn: 'lambda_invoke_arn goes here šŸ“Œ', + }, + }, + }, + }: { + apex: string + subdomainRoutes: Subdomains + }, + my: { [key: string]: AWS } +) => ({ + zone: rout53_zone({ apex }), // šŸ“Œ outside module scope? + ...Object.entries(subdomainRoutes).reduce( + (a, [sd, routes]) => ({ + ...a, + [`cert_${sd}`]: acm_cert({ apex, subdomain: sd }), + [`domain_${sd}`]: api_domain({ + subdomain: sd, + apex, + cert_arn: + my?.[`validation_${sd}`]?.resource?.acm_certificate_validation?.certificate_arn, + }), + [`record_${sd}`]: route53_record({ + route53_zone_id: my?.zone?.data?.route53_zone?.zone_id, + name: sd, + api_domain_name: + my?.[`domain_${sd}`]?.resource?.apigatewayv2_domain_name + ?.domain_name_configuration[0]?.target_domain_name, + api_hosted_zone_id: + my?.[`domain_${sd}`]?.resource?.apigatewayv2_domain_name + ?.domain_name_configuration[0]?.hosted_zone_id, + }), + [`record_valid_${sd}`]: route53_record({ + route53_zone_id: my?.zone?.data?.route53_zone?.zone_id, + records: [ + my?.[`cert_${sd}`]?.resource?.acm_certificate?.domain_validation_options[0] + ?.resource_record_value, + ], + name: my?.[`cert_${sd}`]?.resource?.acm_certificate?.domain_validation_options[0] + ?.resource_record_name, + type: my?.[`cert_${sd}`]?.resource?.acm_certificate?.domain_validation_options[0] + ?.resource_record_type, + }), + [`validation_${sd}`]: acm_validation({ + cert_arn: my?.[`cert_${sd}`]?.resource?.acm_certificate?.arn, + fqdns: [my?.[`record_valid_${sd}`]?.resource?.route53_record?.fqdn], + }), // TODO + [`gateway_${sd}`]: api_gateway({ name: sd }), + [`stage_${sd}`]: api_stage({ + api_id: my?.[`gateway_${sd}`]?.resource?.apigatewayv2_api?.id, + }), + ...Object.entries(routes).reduce( + (acc, [route, { invoke_arn }]) => ({ + ...acc, + [`integration_${sd}_${route.split(' ')[0]}`]: api_lambda_integration({ + api_id: my?.[`gateway_${sd}`]?.resource?.apigatewayv2_api?.id, + lambda_invoke_arn: invoke_arn, + }), + [`route_${sd}_${route.split(' ')[0]}`]: api_route({ + api_id: my?.[`gateway_${sd}`]?.resource?.apigatewayv2_api?.id, + route_key: route, + integration_id: + my?.[`integration_${sd}_${route.split(' ')[0]}`]?.resource + ?.apigatewayv2_integration?.id, + }), + }), + {} + ), + }), + {} + ), +}) diff --git a/examples/example.ts b/examples/example.ts new file mode 100644 index 0000000..ce4a5db --- /dev/null +++ b/examples/example.ts @@ -0,0 +1,120 @@ +import { modulate, config, Provider, Terraform } from '../src/config' +import { AWS05200 as AWS } from '../registry/index' +import { lambda } from './lambda' +import { subdomains } from './api' + +const provider: Provider = { + aws: { + region: 'us-east-2', + profile: 'chopshop', + }, +} + +const terraform: Terraform = { + required_providers: { + aws: { + source: 'hashicorp/aws', + version: '5.20.0', + }, + }, +} + +/** + * + * my?.zone?.data?.route53_zone?.zone_id + */ + +const module = modulate({ ms1: lambda }) + +const [mod_lambda, out_lambda] = module({ name: 'throwaway-test-123' }) + +const functionInvokeArn = out_lambda?.lambda?.resource?.lambda_function?.invoke_arn + +const moduleAPI = modulate({ subdomains }) + +const [mod_api, out_api] = moduleAPI({ + apex: 'chopshop-test.net', + subdomainRoutes: { + test1: { + 'ANY /': { + invoke_arn: functionInvokeArn, + }, + }, + }, +}) + +// ,e, 888 888 +// e88~~\ e88~-_ 888-~88e-~88e 888-~88e " 888 e88~~8e e88~\888 +// d888 d888 i 888 888 888 888 888b 888 888 d888 88b d888 888 +// 8888 8888 | 888 888 888 888 8888 888 888 8888__888 8888 888 +// Y888 Y888 ' 888 888 888 888 888P 888 888 Y888 , Y888 888 +// "88__/ "88_-~ 888 888 888 888-_88" 888 888 "88___/ "88_/888 +// 888 + +JSON.stringify(out_api, null, 4) // +JSON.stringify(mod_api, null, 4) // + +const compiler = config(provider, terraform, 'main.tf.json') +const compiled = compiler(mod_lambda, mod_api) + +JSON.stringify(compiled, null, 4) //? + +/** + * References: + * [1]: https://dev.to/madflanderz/how-to-get-parts-of-an-typescript-interface-3mko + * [2]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route#argument-reference + * [3]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration#response_parameters + * [4]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function#lambda-file-systems + */ + +// ~~~888~~~ ,88~-_ 888~-_ ,88~-_ +// 888 d888 \ 888 \ d888 \ +// 888 88888 | 888 | 88888 | +// 888 88888 | 888 | 88888 | +// 888 Y888 / 888 / Y888 / +// 888 `88_-~ 888_-~ `88_-~ + +// - add ability to add tags at the module level +// - missing tick_groups - (top three) in route53_record +// - EFSAccessPoint - missing `file_system_arn` (not in docs) +// - resource: { lambda_function: { file_system_config +// - topic: sns_topic(name), // šŸ“Œ outside module scope? +// - apigatewayv2_route šŸ› [2] `request_parameter_key` and `required` bug in docs (nested under section without heading) +// - apigatewayv2_integration: šŸ› [3] `status_code` and `mappings` bug in docs (nested under section without heading) + +/** + * Outline of microservice module: + * - s3 + * - bucket + * - sns + * - upstream topic (subscribed to by lambda) + * - downstream topic (published to by lambda) + * - lambda + * - environment variables + * - S3_BUCKET_NAME (for functions that need to read/write to s3) + * - SNS_TOPIC_ARN (downstream topic to publish to sns) + * - sns subscription (upstream topic) + * - filter policy + * - elastic file system (optional) + * - access point + * - mount target + * - api gateway (optional) + * - domain + * - subdomain + * - routes + * - methods + * - required iam permissions + * - lambda + * - s3 + * - read/write + * - sns + * - publish + * - subscribe + * - cloudwatch + * - logs + * - metrics + * - sns + * - lambda (via AllowExecutionFromSNS) + * - api gateway (optional) + * - lambda (via AllowExecutionFromAPIGateway) + */ diff --git a/examples/lambda.ts b/examples/lambda.ts new file mode 100644 index 0000000..f30577a --- /dev/null +++ b/examples/lambda.ts @@ -0,0 +1,367 @@ +import { modulate, config, Provider, Terraform } from '../src/config' +import { AWS05200 as AWS } from '../registry/index' + +// ,e, +// " /~~~8e 888-~88e-~88e +// 888 88b 888 888 888 +// 888 e88~-888 888 888 888 +// 888 C888 888 888 888 888 +// 888 "88_-888 888 888 888 + +/** + * The following type customizations provide an example of how to modify a block + * to allow for Array values in addition to default interfaces... + * + * reference blog [1] + */ +type AWSData = NonNullable +type IamPolicyDocument = NonNullable +type Statement = NonNullable +interface Statements extends Statement { + [index: number]: Statement +} +interface IamPolicyDocs extends IamPolicyDocument { + statement: Statement | Statements +} +interface AWSDataColls extends AWSData { + iam_policy_document: IamPolicyDocs +} +interface AWSColl extends AWS { + data: AWSDataColls +} + +const lambda_creds: AWS = { + data: { + iam_policy_document: { + statement: { + effect: 'Allow', + actions: ['sts:AssumeRole'], + principals: { + identifiers: ['lambda.amazonaws.com', 'apigateway.amazonaws.com'], + type: 'Service', + }, + }, + json: '-->', + }, + }, +} + +const lambda_role = ({ name, policy_json }): AWS => ({ + resource: { + iam_role: { + name: `-->${name}-role`, + assume_role_policy: policy_json, + arn: '-->', + }, + }, +}) + +const lambda_access_creds = ({ + bucket_name = '', + topic_arn = '', + cloudwatch_arn = '', +}): AWSColl => ({ + data: { + iam_policy_document: { + statement: [ + ...(bucket_name + ? [ + { + effect: 'Allow', + actions: [ + 's3:AbortMultipartUpload', + 's3:ListMultipartUploadParts', + 's3:ListBucketMultipartUploads', + 's3:PutObject', + 's3:GetObject', + 's3:DeleteObject', + ], + resources: [ + `arn:aws:s3:::${bucket_name}`, + `arn:aws:s3:::${bucket_name}/*`, + ], + }, + ] + : []), + ...(topic_arn + ? [ + { + effect: 'Allow', + actions: ['sns:Publish', 'sns:Subscribe'], + resources: [topic_arn], + }, + ] + : []), + ...(cloudwatch_arn + ? [ + { + effect: 'Allow', + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + resources: [`${cloudwatch_arn}:*`, `${cloudwatch_arn}:*:*`], + }, + ] + : []), + ], + json: '-->', + }, + }, +}) + +const lambda_policy_attachment = ({ role_name, policy_arn }): AWS => ({ + resource: { + iam_role_policy_attachment: { + role: role_name, + policy_arn, + }, + }, +}) + +const lambda_policy = ({ name, policy_json }): AWS => ({ + resource: { + iam_policy: { + name: `-->${name}-policy`, + policy: policy_json, + arn: '-->', + }, + }, +}) + +const lambda_invoke_cred = ({ + function_name, + source_arn, + principal = 'sns.amazonaws.com', + statement_id = 'AllowExecutionFromSNS', +}): AWS => ({ + resource: { + lambda_permission: { + statement_id, + action: 'lambda:InvokeFunction', + function_name, + principal, + source_arn, + }, + }, +}) + +// 888 888 d8 888 +// e88~~\ 888 e88~-_ 888 888 e88~\888 Y88b e / /~~~8e _d88__ e88~~\ 888-~88e +// d888 888 d888 i 888 888 d888 888 Y88b d8b / 88b 888 d888 888 888 +// 8888 888 8888 | 888 888 8888 888 Y888/Y88b/ e88~-888 888 8888 888 888 +// Y888 888 Y888 ' 888 888 Y888 888 Y8/ Y8/ C888 888 888 Y888 888 888 +// "88__/ 888 "88_-~ "88_-888 "88_/888 Y Y "88_-888 "88_/ "88__/ 888 888 + +const cloudwatch = ({ name, retention_in_days = 7 }): AWS => ({ + resource: { + cloudwatch_log_group: { + name: `/aws/lambda/${name}-log-group`, + retention_in_days, + arn: '-->', + }, + }, +}) + +// d88~\ 888-~88e d88~\ +// C888 888 888 C888 +// Y88b 888 888 Y88b +// 888D 888 888 888D +// \_88P 888 888 \_88P + +const sns_topic = (name): AWS => ({ + resource: { + sns_topic: { + name: `${name}-topic`, + arn: '-->', + }, + }, +}) + +const subscription = ({ + topic_arn, + lambda_arn, + filter = {}, + scope = 'MessageAttributes', +}): AWS => ({ + resource: { + // @ts-ignore: subscription_role_arn only needed if protocol == 'firehose' + sns_topic_subscription: { + topic_arn, + protocol: 'lambda', + endpoint: lambda_arn, + filter_policy: JSON.stringify(filter), + filter_policy_scope: scope, + arn: '-->', + }, + }, +}) + +// 88~\ +// e88~~8e _888__ d88~\ +// d888 88b 888 C888 +// 8888__888 888 Y88b +// Y888 , 888 888D +// "88___/ 888 \_88P + +// reference [4] + +const efs: AWS = { + resource: { + efs_file_system: { + arn: '-->', + tags: { + Source: 'Micro', + }, + }, + }, +} + +const efs_access_point = ({ name, efs_arn }): AWS => ({ + resource: { + efs_access_point: { + file_system_id: 'TODO', + }, + }, +}) + +// _-~88e +// d88~\ 888b +// C888 __888" +// Y88b 888e +// 888D 888P +// \_88P ~-_88" + +const s3 = (name): AWS => ({ + resource: { + s3_bucket: { + bucket: `-->${name}-bucket`, + }, + }, +}) + +// 888 888 888 +// 888 /~~~8e 888-~88e-~88e 888-~88e e88~\888 /~~~8e +// 888 88b 888 888 888 888 888b d888 888 88b +// 888 e88~-888 888 888 888 888 8888 8888 888 e88~-888 +// 888 C888 888 888 888 888 888 888P Y888 888 C888 888 +// 888 "88_-888 888 888 888 888-_88" "88_/888 "88_-888 + +const lambda_fn = ({ + name, + //efs_arn, + role_arn, + file_path, + env_vars = {}, + handler = 'handler.handler', + runtime = 'python3.8', +}): AWS => ({ + resource: { + lambda_function: { + function_name: `-->lambda-${name}`, + role: role_arn, + runtime, + handler, + filename: file_path, + //file_system_config: { + // arn: efs_arn, + // local_mount_path: '/mnt/efs', + //}, + environment: { + variables: env_vars, + }, + arn: '-->', + invoke_arn: '-->', + }, + }, +}) + +// 888 888 ,d +// 888-~88e-~88e e88~-_ e88~\888 888 888 888 e88~~8e ,d888 +// 888 888 888 d888 i d888 888 888 888 888 d888 88b 888 +// 888 888 888 8888 | 8888 888 888 888 888 8888__888 888 +// 888 888 888 Y888 ' Y888 888 888 888 888 Y888 , 888 +// 888 888 888 "88_-~ "88_/888 "88_-888 888 "88___/ 888 + +/** + * micro service module + * + * @param name - name of the micro service + * @param subdomain - subdomain of the micro service + * @param file_path - path to the lambda function zip file + * @param handler - name of the lambda handler function + * @param env_vars - environment variables for the lambda function + * @param filter - filter policy for sns subscription + * @param my - self reference for referencing other resources + * + * @returns - micro service module + * + * @example + * ```ts + * const module = modulate({ ms1: microServiceModule }) + * const output = module({ name: 'throwaway-test-123', subdomain: 'bloop' }) + * const compiler = config(provider, terraform, 'main.tf.json') + * const compiled = compiler(output) + * ``` + */ +export const lambda = ( + { + name = 'microservice', + file_path = '${path.root}/lambdas/template/zipped/handler.py.zip', + handler = 'handler.handler', + filter = { type: ['type1', 'type2'] }, + env_vars = {}, + }, + my: { [key: string]: AWS } +) => ({ + //efs, + lambda_creds, + cloudwatch: cloudwatch({ name }), + lambda_policy: lambda_policy({ + name: `${name}-policy`, + policy_json: my?.lambda_access_creds?.data?.iam_policy_document?.json, + }), + lambda_role: lambda_role({ + name, + policy_json: my?.lambda_creds?.data?.iam_policy_document?.json, + }), + lambda_policy_attachment: lambda_policy_attachment({ + policy_arn: my?.lambda_policy?.resource?.iam_policy?.arn, + role_name: my?.lambda_role?.resource?.iam_role?.name, + }), + s3: s3(name), + lambda: lambda_fn({ + name, + //efs_arn: my?.efs?.resource?.efs_file_system?.arn, + role_arn: my?.lambda_role?.resource?.iam_role?.arn, + file_path, + handler, + env_vars: { + S3_BUCKET_NAME: my?.s3.resource?.s3_bucket?.bucket, + ...(filter ? { SNS_TOPIC_ARN: my?.topic?.resource?.sns_topic?.arn } : {}), + ...env_vars, + }, + }), + lambda_access_creds: lambda_access_creds({ + bucket_name: my?.s3.resource?.s3_bucket?.bucket, + cloudwatch_arn: my?.cloudwatch.resource?.cloudwatch_log_group?.arn, + topic_arn: filter ? my?.topic?.resource?.sns_topic?.arn : null, + }), + ...(filter + ? { + topic: sns_topic(name), // šŸ“Œ outside module scope? + sns_invoke_cred: lambda_invoke_cred({ + function_name: my?.lambda?.resource?.lambda_function?.function_name, + source_arn: my?.topic?.resource?.sns_topic?.arn, + principal: 'sns.amazonaws.com', + statement_id: 'AllowExecutionFromSNS', + }), + subscription: subscription({ + topic_arn: my?.topic?.resource?.sns_topic?.arn, + lambda_arn: my?.lambda?.resource?.lambda_function?.arn, + filter, + }), + } + : {}), +}) diff --git a/examples/module.ts b/examples/module.ts deleted file mode 100644 index 4f0fca4..0000000 --- a/examples/module.ts +++ /dev/null @@ -1,780 +0,0 @@ -import { modulate, config, Provider, Terraform } from '../src/config' -import { AWS05200 as AWS } from '../registry/index' - -/** - * Outline of microservice module: - * - s3 - * - bucket - * - sns - * - upstream topic (subscribed to by lambda) - * - downstream topic (published to by lambda) - * - lambda - * - environment variables - * - S3_BUCKET_NAME (for functions that need to read/write to s3) - * - SNS_TOPIC_ARN (downstream topic to publish to sns) - * - sns subscription (upstream topic) - * - filter policy - * - elastic file system (optional) - * - access point - * - mount target - * - api gateway (optional) - * - domain - * - subdomain - * - routes - * - methods - * - required iam permissions - * - lambda - * - s3 - * - read/write - * - sns - * - publish - * - subscribe - * - cloudwatch - * - logs - * - metrics - * - sns - * - lambda (via AllowExecutionFromSNS) - * - api gateway (optional) - * - lambda (via AllowExecutionFromAPIGateway) - */ - -// ,e, -// " /~~~8e 888-~88e-~88e -// 888 88b 888 888 888 -// 888 e88~-888 888 888 888 -// 888 C888 888 888 888 888 -// 888 "88_-888 888 888 888 - -/** - * The following type customizations provide an example of how to modify a block - * to allow for Array values in addition to default interfaces... - * - * reference blog [1] - */ -type AWSData = NonNullable -type IamPolicyDocument = NonNullable -type Statement = NonNullable -interface Statements extends Statement { - [index: number]: Statement -} -interface IamPolicyDocs extends IamPolicyDocument { - statement: Statement | Statements -} -interface AWSDataColls extends AWSData { - iam_policy_document: IamPolicyDocs -} -interface AWSColl extends AWS { - data: AWSDataColls -} - -const lambda_creds: AWS = { - data: { - iam_policy_document: { - statement: { - effect: 'Allow', - actions: ['sts:AssumeRole'], - principals: { - identifiers: ['lambda.amazonaws.com', 'apigateway.amazonaws.com'], - type: 'Service', - }, - }, - json: '-->', - }, - }, -} - -const lambda_role = ({ name, policy_json }): AWS => ({ - resource: { - iam_role: { - name: `-->${name}-role`, - assume_role_policy: policy_json, - arn: '-->', - }, - }, -}) - -const lambda_access_creds = ({ - bucket_name = '', - topic_arn = '', - cloudwatch_arn = '', -}): AWSColl => ({ - data: { - iam_policy_document: { - statement: [ - ...(bucket_name - ? [ - { - effect: 'Allow', - actions: [ - 's3:AbortMultipartUpload', - 's3:ListMultipartUploadParts', - 's3:ListBucketMultipartUploads', - 's3:PutObject', - 's3:GetObject', - 's3:DeleteObject', - ], - resources: [ - `arn:aws:s3:::${bucket_name}`, - `arn:aws:s3:::${bucket_name}/*`, - ], - }, - ] - : []), - ...(topic_arn - ? [ - { - effect: 'Allow', - actions: ['sns:Publish', 'sns:Subscribe'], - resources: [topic_arn], - }, - ] - : []), - ...(cloudwatch_arn - ? [ - { - effect: 'Allow', - actions: [ - 'logs:CreateLogGroup', - 'logs:CreateLogStream', - 'logs:PutLogEvents', - ], - resources: [`${cloudwatch_arn}:*`, `${cloudwatch_arn}:*:*`], - }, - ] - : []), - ], - json: '-->', - }, - }, -}) - -const lambda_policy_attachment = ({ role_name, policy_arn }): AWS => ({ - resource: { - iam_role_policy_attachment: { - role: role_name, - policy_arn, - }, - }, -}) - -const lambda_policy = ({ name, policy_json }): AWS => ({ - resource: { - iam_policy: { - name: `-->${name}-policy`, - policy: policy_json, - arn: '-->', - }, - }, -}) - -const lambda_invoke_cred = ({ - function_name, - source_arn, - principal = 'sns.amazonaws.com', - statement_id = 'AllowExecutionFromSNS', -}): AWS => ({ - resource: { - lambda_permission: { - statement_id, - action: 'lambda:InvokeFunction', - function_name, - principal, - source_arn, - /** - * TODO: add qualifier? - * (Optional) Query parameter to specify function version or alias - * name. The permission will then apply to the specific qualified - * ARN - * e.g... - */ - //qualifier: "arn:aws:lambda:aws-region:acct-id:function:function-name:2", - }, - }, -}) - -// 888 888 d8 888 -// e88~~\ 888 e88~-_ 888 888 e88~\888 Y88b e / /~~~8e _d88__ e88~~\ 888-~88e -// d888 888 d888 i 888 888 d888 888 Y88b d8b / 88b 888 d888 888 888 -// 8888 888 8888 | 888 888 8888 888 Y888/Y88b/ e88~-888 888 8888 888 888 -// Y888 888 Y888 ' 888 888 Y888 888 Y8/ Y8/ C888 888 888 Y888 888 888 -// "88__/ 888 "88_-~ "88_-888 "88_/888 Y Y "88_-888 "88_/ "88__/ 888 888 - -const cloudwatch = ({ name, retention_in_days = 7 }): AWS => ({ - resource: { - cloudwatch_log_group: { - name: `/aws/lambda/${name}-log-group`, - retention_in_days, - arn: '-->', - }, - }, -}) - -// d88~\ 888-~88e d88~\ -// C888 888 888 C888 -// Y88b 888 888 Y88b -// 888D 888 888 888D -// \_88P 888 888 \_88P - -const sns_topic = (name): AWS => ({ - resource: { - sns_topic: { - name: `${name}-topic`, - arn: '-->', - }, - }, -}) - -const subscription = ({ - topic_arn, - lambda_arn, - filter = {}, - scope = 'MessageAttributes', -}): AWS => ({ - resource: { - sns_topic_subscription: { - topic_arn, - protocol: 'lambda', - endpoint: lambda_arn, - filter_policy: JSON.stringify(filter), - filter_policy_scope: scope, - subscription_role_arn: undefined, // only needed if protocol == 'firehose' - arn: '-->', - }, - }, -}) - -// 88~\ -// e88~~8e _888__ d88~\ -// d888 88b 888 C888 -// 8888__888 888 Y88b -// Y888 , 888 888D -// "88___/ 888 \_88P - -// reference [4] -// TODO -const efs: AWS = { - resource: { - efs_file_system: { - arn: '-->', - tags: { - Source: 'Micro', - }, - }, - }, -} - -const efs_access_point = ({ name, efs_arn }): AWS => ({ - resource: { - efs_access_point: { - file_system_id: 'TODO', - }, - }, -}) - -// _-~88e -// d88~\ 888b -// C888 __888" -// Y88b 888e -// 888D 888P -// \_88P ~-_88" - -const s3 = (name): AWS => ({ - resource: { - s3_bucket: { - bucket: `-->${name}-bucket`, - }, - }, -}) - -// 888 888 888 -// 888 /~~~8e 888-~88e-~88e 888-~88e e88~\888 /~~~8e -// 888 88b 888 888 888 888 888b d888 888 88b -// 888 e88~-888 888 888 888 888 8888 8888 888 e88~-888 -// 888 C888 888 888 888 888 888 888P Y888 888 C888 888 -// 888 "88_-888 888 888 888 888-_88" "88_/888 "88_-888 - -const lambda = ({ - name, - //efs_arn, - role_arn, - file_path, - env_vars = {}, - handler = 'handler.handler', - runtime = 'python3.8', -}): AWS => ({ - resource: { - lambda_function: { - function_name: `-->lambda-${name}`, - role: role_arn, - runtime, - handler, - filename: file_path, - //file_system_config: { - // arn: efs_arn, - // local_mount_path: '/mnt/efs', - //}, - environment: { - variables: env_vars, - }, - arn: '-->', - invoke_arn: '-->', - }, - }, -}) - -// 888 -// d88~\ 888 888 888-~88e -// C888 888 888 888 888b -// Y88b 888 888 888 8888 -// 888D 888 888 888 888P -// \_88P "88_-888 888-_88" - -/** - * micro service module - * - * @param name - name of the micro service - * @param subdomain - subdomain of the micro service - * @param file_path - path to the lambda function zip file - * @param handler - name of the lambda handler function - * @param env_vars - environment variables for the lambda function - * @param filter - filter policy for sns subscription - * @param my - self reference for referencing other resources - * - * @returns - micro service module - * - * @example - * ```ts - * const module = modulate({ ms1: microServiceModule }) - * const output = module({ name: 'throwaway-test-123', subdomain: 'bloop' }) - * const compiler = config(provider, terraform, 'main.tf.json') - * const compiled = compiler(output) - * ``` - */ -export const microServiceModule = ( - { - name = 'microservice', - file_path = '${path.root}/lambdas/template/zipped/handler.py.zip', - handler = 'handler.handler', - filter = { type: ['type1', 'type2'] }, - env_vars = {}, - }, - my: { [key: string]: AWS } -) => ({ - //efs, - lambda_creds, - cloudwatch: cloudwatch({ name }), - lambda_policy: lambda_policy({ - name: `${name}-policy`, - policy_json: my?.lambda_access_creds?.data?.iam_policy_document?.json, - }), - lambda_role: lambda_role({ - name, - policy_json: my?.lambda_creds?.data?.iam_policy_document?.json, - }), - lambda_policy_attachment: lambda_policy_attachment({ - policy_arn: my?.lambda_policy?.resource?.iam_policy?.arn, - role_name: my?.lambda_role?.resource?.iam_role?.name, - }), - s3: s3(name), - lambda: lambda({ - name, - //efs_arn: my?.efs?.resource?.efs_file_system?.arn, - role_arn: my?.lambda_role?.resource?.iam_role?.arn, - file_path, - handler, - env_vars: { - S3_BUCKET_NAME: my?.s3.resource?.s3_bucket?.bucket, - ...(filter ? { SNS_TOPIC_ARN: my?.topic?.resource?.sns_topic?.arn } : {}), - ...env_vars, - }, - }), - lambda_access_creds: lambda_access_creds({ - bucket_name: my?.s3.resource?.s3_bucket?.bucket, - cloudwatch_arn: my?.cloudwatch.resource?.cloudwatch_log_group?.arn, - topic_arn: filter ? my?.topic?.resource?.sns_topic?.arn : null, - }), - ...(filter - ? { - topic: sns_topic(name), // šŸ“Œ outside module scope? - sns_invoke_cred: lambda_invoke_cred({ - function_name: my?.lambda?.resource?.lambda_function?.function_name, - source_arn: my?.topic?.resource?.sns_topic?.arn, - principal: 'sns.amazonaws.com', - statement_id: 'AllowExecutionFromSNS', - }), - subscription: subscription({ - topic_arn: my?.topic?.resource?.sns_topic?.arn, - lambda_arn: my?.lambda?.resource?.lambda_function?.arn, - filter, - }), - } - : {}), -}) - -const module = modulate({ ms1: microServiceModule }) - -const [msOutput, msRefs] = module({ name: 'throwaway-test-123' }) - -const functionInvokeArn = msRefs?.lambda?.resource?.lambda_function?.invoke_arn - -// d8 /~~~~~~ _-~88e -// 888-~\ e88~-_ 888 888 _d88__ e88~~8e / 888b -// 888 d888 i 888 888 888 d888 88b `-~~88e __888" -// 888 8888 | 888 888 888 8888__888 / 888b 888e -// 888 Y888 ' 888 888 888 Y888 , | 888P 888P -// 888 "88_-~ "88_-888 "88_/ "88___/ \__88" ~-_88" - -// šŸ¤” only need one of these per domain... (not in module?) -const rout53_zone = ({ apex = 'chopshop-test.net' }): AWS => ({ - data: { - route53_zone: { - name: apex, - zone_id: '-->', - }, - }, -}) - -/* -resource "aws_acm_certificate" "example" { - domain_name = "example.com" - validation_method = "DNS" -} - -resource "aws_apigatewayv2_domain_name" "example" { - domain_name = "example.com" - domain_name_configuration { - certificate_arn = aws_acm_certificate.example.arn - endpoint_type = "REGIONAL" - } -} -*/ -// šŸ¤” only need one of these per domain... (not in module?) -const acm_cert = ({ subdomain, apex = 'chopshop-test.net' }): AWS => ({ - resource: { - acm_certificate: { - domain_name: apex, - validation_method: 'DNS', - subject_alternative_names: [`${subdomain}.${apex}`], - tags: { - BroughtToYouBy: '@-0/micro', - }, - // @ts-ignore -> terraform meta argument (not in docs) - lifecycle: { - create_before_destroy: true, - }, - arn: '-->', - }, - }, -}) - -const acm_validation = ({ cert_arn }): AWS => ({ - resource: { - acm_certificate_validation: { - certificate_arn: cert_arn, - }, - }, -}) - -//const route53_record_valid = { name, zone_id } - -// FIXME (missing tick_groups - top three) -const route53_record = ({ name, route53_zone_id, api_domain_name, api_hosted_zone_id }): AWS => ({ - resource: { - route53_record: { - name, - // @ts-ignore šŸ› missing docs - zone_id: route53_zone_id, - // šŸ› missing docs - type: 'A', - alias: { - name: api_domain_name, - zone_id: api_hosted_zone_id, - evaluate_target_health: false, - }, - //depends_on, - }, - }, -}) - -// ,e, -// /~~~8e 888-~88e " -// 88b 888 888b 888 -// e88~-888 888 8888 888 -// C888 888 888 888P 888 -// "88_-888 888-_88" 888 -// 888 - -const api_domain = ({ subdomain = 'api', apex = 'chopshop-test.net', cert_arn }): AWS => ({ - resource: { - apigatewayv2_domain_name: { - domain_name: `${subdomain}.${apex}`, - /** - * Block type "domain_name_configuration" is represented by a list - * of objects, so it must be indexed using a numeric key, like - * .domain_name_configuration[0] - * - * FIXME? - */ - // @ts-ignore - domain_name_configuration: [ - { - certificate_arn: cert_arn, - endpoint_type: 'REGIONAL', - security_policy: 'TLS_1_2', - target_domain_name: '-->', - hosted_zone_id: '-->', - }, - ], - tags: { - Name: `${subdomain}.${apex}`, - BroughtToYouBy: '@-0/micro', - }, - }, - }, -}) - -const api_gateway = ({ name }): AWS => ({ - resource: { - apigatewayv2_api: { - name, - description: `api for ${name}`, - disable_execute_api_endpoint: false, - protocol_type: 'HTTP', - cors_configuration: { - allow_headers: [ - 'content-type', - 'x-amz-date', - 'authorization', - 'x-api-key', - 'x-amz-security-token', - 'x-amz-user-agent', - ], - allow_methods: ['*'], - allow_origins: ['*'], - max_age: 300, - }, - api_endpoint: '-->', - execution_arn: '-->', - id: '-->', - }, - }, -}) - -const api_stage = ({ api_id, name = '$default' }): AWS => ({ - resource: { - apigatewayv2_stage: { - api_id, - name, - auto_deploy: true, - description: `stage ${name} API`, - }, - }, -}) - -// TODO: https://github.com/terraform-aws-modules/terraform-aws-apigateway-v2/blob/master/main.tf - -const api_lambda_integration = ({ api_id, lambda_invoke_arn }): AWS => ({ - resource: { - apigatewayv2_integration: { - api_id, - integration_uri: lambda_invoke_arn, - integration_type: 'AWS_PROXY', - integration_method: 'POST', - connection_type: 'INTERNET', - payload_format_version: '2.0', - timeout_milliseconds: 29000, // 30 sec max for HTTP, 29 for WebSockets - id: '-->', - - // šŸ›šŸ› [3] bug in docs (nested under section without heading) - status_code: undefined, - mappings: undefined, - }, - }, -}) - -const api_route = ({ api_id, route_key = 'ANY /', integration_id }): AWS => ({ - resource: { - apigatewayv2_route: { - api_id, - route_key, - target: `integrations/${integration_id}`, - id: '-->', - - //authorization_scopes: 'TODO', - //authorization_type: 'TODO', - //authorizer_id: 'TODO' - - // šŸ›šŸ› [2] bug in docs (nested under section without heading) - request_parameter_key: undefined, - required: undefined, - }, - }, -}) - -interface Subdomains { - /** subdomain name (e.g., "api")*/ - [key: string]: { - /** route (e.g., "GET /") */ - [key: string]: { - invoke_arn: string - } - } -} -/** - * subdomains module - * - * @param apex - apex domain name - * @param subdomains - array of subdomains - * - name - name of the subdomain - * - lambda_integration - lambda integration object - * - lambda_invoke_arn - arn of the lambda function to integrate - * - routes - array of routes - * - route object - * - route_key - route key - * - integration_id - id of the integration to use - * @param my - self reference for referencing other resources - * - */ -const subdomains = ( - { - apex = 'chopshop-test.net', - subdomainRoutes = { - // TODO add a bunch of defaults to cover many use cases - test: { - 'ANY /': { - invoke_arn: 'lambda_invoke_arn goes here šŸ“Œ', - }, - }, - }, - }: { - apex: string - subdomainRoutes: Subdomains - }, - my: { [key: string]: AWS } -) => ({ - zone: rout53_zone({ apex }), - ...Object.entries(subdomainRoutes).reduce( - (a, [subd, routes], i) => ({ - ...a, - [`cert_${subd}`]: acm_cert({ apex, subdomain: subd }), - [`validation_${subd}`]: acm_validation({ - cert_arn: my?.[`cert_${subd}`]?.resource?.acm_certificate?.arn, - }), // TODO - [`domain_${subd}`]: api_domain({ - subdomain: subd, - apex, - cert_arn: my?.[`cert_${subd}`]?.resource?.acm_certificate?.arn, - }), - [`record_${subd}`]: route53_record({ - name: subd, - api_domain_name: - my?.[`domain_${subd}`]?.resource?.apigatewayv2_domain_name - ?.domain_name_configuration[0]?.target_domain_name, - api_hosted_zone_id: - my?.[`domain_${subd}`]?.resource?.apigatewayv2_domain_name - ?.domain_name_configuration[0]?.hosted_zone_id, - route53_zone_id: my?.zone?.data?.route53_zone?.zone_id, - }), - [`gateway_${subd}`]: api_gateway({ name: subd }), - [`stage_${subd}`]: api_stage({ - api_id: my?.[`gateway_${subd}`]?.resource?.apigatewayv2_api?.id, - }), - ...Object.entries(routes).reduce( - (acc, [route, { invoke_arn }], idx) => ({ - ...acc, - [`integration_${subd}_${route.split(' ')[0]}`]: api_lambda_integration({ - api_id: my?.[`gateway_${subd}`]?.resource?.apigatewayv2_api?.id, - lambda_invoke_arn: invoke_arn, - }), - [`route_${subd}_${route.split(' ')[0]}`]: api_route({ - api_id: my?.[`gateway_${subd}`]?.resource?.apigatewayv2_api?.id, - route_key: route, - integration_id: - my?.[`integration_${subd}_${route.split(' ')[0]}`]?.resource - ?.apigatewayv2_integration?.id, - }), - }), - {} - ), - }), - {} - ), -}) - -const moduleAPI = modulate({ subdomains }) - -const [apiOutput, apiRefs] = moduleAPI({ - apex: 'chopshop-test.net', - subdomainRoutes: { - test1: { - 'ANY /': { - invoke_arn: functionInvokeArn, - }, - }, - test2: { - 'GET /': { - invoke_arn: 'lambda_invoke_arn goes here šŸ“Œ', - }, - }, - }, -}) -JSON.stringify(apiRefs, null, 4) //? -JSON.stringify(apiOutput, null, 4) // - -// ,e, 888 888 -// e88~~\ e88~-_ 888-~88e-~88e 888-~88e " 888 e88~~8e e88~\888 -// d888 d888 i 888 888 888 888 888b 888 888 d888 88b d888 888 -// 8888 8888 | 888 888 888 888 8888 888 888 8888__888 8888 888 -// Y888 Y888 ' 888 888 888 888 888P 888 888 Y888 , Y888 888 -// "88__/ "88_-~ 888 888 888 888-_88" 888 888 "88___/ "88_/888 -// 888 -const provider: Provider = { - aws: { - region: 'us-east-2', - profile: 'chopshop', - }, -} - -const terraform: Terraform = { - required_providers: { - aws: { - source: 'hashicorp/aws', - version: '5.20.0', - }, - }, -} -const compiler = config(provider, terraform, 'main.tf.json') -const compiled = compiler(msOutput, apiOutput) - -JSON.stringify(compiled, null, 4) //? - -/** - * References: - * [1]: https://dev.to/madflanderz/how-to-get-parts-of-an-typescript-interface-3mko - * [2]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route#argument-reference - * [3]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration#response_parameters - * [4]: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function#lambda-file-systems - */ - - -/* - -TODO: - -==================================================================================================== - - -Error: creating API Gateway v2 Domain Name (test1.chopshop-test.net): -BadRequestException: Certificate -arn:aws:acm:us-east-2:477330550029:certificate/9bd98d56-a6d4-4e42-8cda-1ef4d3531202 -in account 477330550029 not yet issued (Service: AWSCertificateManager; Status -Code: 400; Error Code: RequestInProgressException; Request ID: -87cc920e-f098-486e-80a1-7c3bfe52927c; Proxy: null) - -with aws_apigatewayv2_domain_name.subdomains_domain_0, on main.tf.json line 169, -in resource.aws_apigatewayv2_domain_name.subdomains_domain_0: 169: } - - -==================================================================================================== - -*/ \ No newline at end of file diff --git a/examples/route53.ts b/examples/route53.ts new file mode 100644 index 0000000..db7df53 --- /dev/null +++ b/examples/route53.ts @@ -0,0 +1,89 @@ +import { AWS05200 as AWS } from '../registry/index' + +// d8 /~~~~~~ _-~88e +// 888-~\ e88~-_ 888 888 _d88__ e88~~8e / 888b +// 888 d888 i 888 888 888 d888 88b `-~~88e __888" +// 888 8888 | 888 888 888 8888__888 / 888b 888e +// 888 Y888 ' 888 888 888 Y888 , | 888P 888P +// 888 "88_-~ "88_-888 "88_/ "88___/ \__88" ~-_88" + +export const rout53_zone = ({ apex = 'chopshop-test.net' }): AWS => ({ + data: { + route53_zone: { + name: apex, + zone_id: '-->', + }, + }, +}) + +export const acm_cert = ({ subdomain, apex = 'chopshop-test.net' }): AWS => ({ + resource: { + acm_certificate: { + domain_name: apex, + validation_method: 'DNS', + subject_alternative_names: [`${subdomain}.${apex}`], + tags: { + BroughtToYouBy: '@-0/micro', + }, + // @ts-ignore -> terraform meta argument (not in docs) + lifecycle: { + create_before_destroy: true, + }, + domain_validation_options: [ + { + resource_record_name: '-->*', + resource_record_type: '-->*', + resource_record_value: '-->*', + }, + ], + arn: '-->', + }, + }, +}) + +export const route53_record = ({ + name, + route53_zone_id, + api_domain_name, + api_hosted_zone_id, + type = 'A', + records = [], +}: { + name: string + route53_zone_id: string + api_domain_name?: string + api_hosted_zone_id?: string + type?: string + records?: string[] +}): AWS => ({ + resource: { + route53_record: { + name, + // @ts-ignore šŸ› missing docs + type, + zone_id: route53_zone_id, + ttl: 60, + allow_overwrite: true, + ...((records.length && { records }) || {}), + // šŸ› missing docs + ...((api_domain_name && { + alias: { + name: api_domain_name, + zone_id: api_hosted_zone_id, + evaluate_target_health: false, + }, + }) || + {}), + fqdn: '-->', // TODO test exclusion + }, + }, +}) + +export const acm_validation = ({ cert_arn, fqdns }): AWS => ({ + resource: { + acm_certificate_validation: { + certificate_arn: `-->${cert_arn}`, + validation_record_fqdns: fqdns, + }, + }, +}) diff --git a/main.tf.json b/main.tf.json index bbe631b..dd307c0 100644 --- a/main.tf.json +++ b/main.tf.json @@ -145,27 +145,6 @@ "lifecycle": { "create_before_destroy": true } - }, - "subdomains_cert_test2": { - "domain_name": "chopshop-test.net", - "validation_method": "DNS", - "subject_alternative_names": [ - "test2.chopshop-test.net" - ], - "tags": { - "BroughtToYouBy": "@-0/micro" - }, - "lifecycle": { - "create_before_destroy": true - } - } - }, - "aws_acm_certificate_validation": { - "subdomains_validation_test1": { - "certificate_arn": "${resource.aws_acm_certificate.subdomains_cert_test1.arn}" - }, - "subdomains_validation_test2": { - "certificate_arn": "${resource.aws_acm_certificate.subdomains_cert_test2.arn}" } }, "aws_apigatewayv2_domain_name": { @@ -173,7 +152,7 @@ "domain_name": "test1.chopshop-test.net", "domain_name_configuration": [ { - "certificate_arn": "${resource.aws_acm_certificate.subdomains_cert_test1.arn}", + "certificate_arn": "${resource.aws_acm_certificate_validation.subdomains_validation_test1.certificate_arn}", "endpoint_type": "REGIONAL", "security_policy": "TLS_1_2" } @@ -182,42 +161,38 @@ "Name": "test1.chopshop-test.net", "BroughtToYouBy": "@-0/micro" } - }, - "subdomains_domain_test2": { - "domain_name": "test2.chopshop-test.net", - "domain_name_configuration": [ - { - "certificate_arn": "${resource.aws_acm_certificate.subdomains_cert_test2.arn}", - "endpoint_type": "REGIONAL", - "security_policy": "TLS_1_2" - } - ], - "tags": { - "Name": "test2.chopshop-test.net", - "BroughtToYouBy": "@-0/micro" - } } }, "aws_route53_record": { "subdomains_record_test1": { "name": "test1", - "zone_id": "${data.aws_route53_zone.subdomains_zone.zone_id}", "type": "A", + "zone_id": "${data.aws_route53_zone.subdomains_zone.zone_id}", + "ttl": 60, + "allow_overwrite": true, "alias": { "name": "${resource.aws_apigatewayv2_domain_name.subdomains_domain_test1.domain_name_configuration[0].target_domain_name}", "zone_id": "${resource.aws_apigatewayv2_domain_name.subdomains_domain_test1.domain_name_configuration[0].hosted_zone_id}", "evaluate_target_health": false } }, - "subdomains_record_test2": { - "name": "test2", + "subdomains_record_valid_test1": { + "name": "${resource.aws_acm_certificate.subdomains_cert_test1.domain_validation_options[0][\"resource_record_name\"]}", + "type": "${resource.aws_acm_certificate.subdomains_cert_test1.domain_validation_options[0][\"resource_record_type\"]}", "zone_id": "${data.aws_route53_zone.subdomains_zone.zone_id}", - "type": "A", - "alias": { - "name": "${resource.aws_apigatewayv2_domain_name.subdomains_domain_test2.domain_name_configuration[0].target_domain_name}", - "zone_id": "${resource.aws_apigatewayv2_domain_name.subdomains_domain_test2.domain_name_configuration[0].hosted_zone_id}", - "evaluate_target_health": false - } + "ttl": 60, + "allow_overwrite": true, + "records": [ + "${resource.aws_acm_certificate.subdomains_cert_test1.domain_validation_options[0][\"resource_record_value\"]}" + ] + } + }, + "aws_acm_certificate_validation": { + "subdomains_validation_test1": { + "certificate_arn": "${resource.aws_acm_certificate.subdomains_cert_test1.arn}", + "validation_record_fqdns": [ + "${resource.aws_route53_record.subdomains_record_valid_test1.fqdn}" + ] } }, "aws_apigatewayv2_api": { @@ -243,29 +218,6 @@ ], "max_age": 300 } - }, - "subdomains_gateway_test2": { - "name": "test2", - "description": "api for test2", - "disable_execute_api_endpoint": false, - "protocol_type": "HTTP", - "cors_configuration": { - "allow_headers": [ - "content-type", - "x-amz-date", - "authorization", - "x-api-key", - "x-amz-security-token", - "x-amz-user-agent" - ], - "allow_methods": [ - "*" - ], - "allow_origins": [ - "*" - ], - "max_age": 300 - } } }, "aws_apigatewayv2_stage": { @@ -274,12 +226,6 @@ "name": "$default", "auto_deploy": true, "description": "stage $default API" - }, - "subdomains_stage_test2": { - "api_id": "${resource.aws_apigatewayv2_api.subdomains_gateway_test2.id}", - "name": "$default", - "auto_deploy": true, - "description": "stage $default API" } }, "aws_apigatewayv2_integration": { @@ -291,15 +237,6 @@ "connection_type": "INTERNET", "payload_format_version": "2.0", "timeout_milliseconds": 29000 - }, - "subdomains_integration_test2_GET": { - "api_id": "${resource.aws_apigatewayv2_api.subdomains_gateway_test2.id}", - "integration_uri": "lambda_invoke_arn goes here šŸ“Œ", - "integration_type": "AWS_PROXY", - "integration_method": "POST", - "connection_type": "INTERNET", - "payload_format_version": "2.0", - "timeout_milliseconds": 29000 } }, "aws_apigatewayv2_route": { @@ -307,11 +244,6 @@ "api_id": "${resource.aws_apigatewayv2_api.subdomains_gateway_test1.id}", "route_key": "ANY /", "target": "integrations/${resource.aws_apigatewayv2_integration.subdomains_integration_test1_ANY.id}" - }, - "subdomains_route_test2_GET": { - "api_id": "${resource.aws_apigatewayv2_api.subdomains_gateway_test2.id}", - "route_key": "GET /", - "target": "integrations/${resource.aws_apigatewayv2_integration.subdomains_integration_test2_GET.id}" } } }, diff --git a/registry/utils/regex.ts b/registry/utils/regex.ts index 6b72086..ab6a0cf 100644 --- a/registry/utils/regex.ts +++ b/registry/utils/regex.ts @@ -68,3 +68,42 @@ export const cleanBody = (md: string) => { const cleaned = replace_em_dashes(dedecorated) return cleaned + '\n' // add newline for tick_group at end of string } + +/** + * uses regex to parse the first object (exclusively) from a function's default + * arguments + * + * @example + * ```ts + * const myFunction = ({ foo = 'bar', baz = 'qux' }, ...rest) => {...} + * const parsed = parseFirstArgObj(myFunction) + * console.log(parsed) // { foo: 'bar', baz: 'qux' } + * ``` + */ +export const parseFirstArgObj = (fn: any) => { + const args = fn.toString().match(/\(([^)]*)\)/)[1] + let obj + try { + // grab the first object using regex + obj = args.match(/{[\s\S]*(?=,\s{0,10}my)/)[0] + } catch (e) { + console.error(`Ensure the second argument to your module is named "my"`) + return {} + } + // replace = with : + const replaced = obj.replace(/ ?={1}/g, ':') + // count the number of opening { + const openers = replaced.match(/{/g) + // count the number of closing } + const closers = replaced.match(/}/g) + // if the number of opening { is greater than the number of closing } + // we need to add the difference to the end of the string + const diff = openers.length - closers.length + const add = Array(diff).fill('}') + const final = replaced + add.join('') + // replace single quotes with double quotes + const double = final.replace(/'/g, '"') + // wrap any symbol keys with quotes + const stringed = double.replace(/([a-zA-Z0-9|_]+?):/g, '"$1":') + return JSON.parse(stringed) +} diff --git a/src/config.ts b/src/config.ts index d71d1d7..2b68ff2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,30 +1,14 @@ import { writeFileSync } from 'fs' -import { isPlainObject, isArray, isFunction, isString } from '@thi.ng/checks' +import { isPlainObject, isArray, isObject, isString } from '@thi.ng/checks' type NestedObject = { [key: string]: NestedObject } -/** - * cleans out any export-specific values (--> prefixed) recursively - */ -const exportCleaner = (obj: object): NestedObject => - Object.entries(obj).reduce((a, c) => { - const [k, v] = c - if (v === undefined || v === '-->') return a - if (isString(v) && v.startsWith('-->')) { - return { ...a, [k]: v.replace('-->', '') } - } else if (isPlainObject(v)) { - return { ...a, [k]: exportCleaner(v) } - } else if (isArray(v)) { - //console.log(`array found for ${k}: ${JSON.stringify(v)}`) - return { ...a, [k]: v.map((x) => (isPlainObject(x) ? exportCleaner(x) : x)) } - } else { - return { ...a, [k]: v } - } - }, {}) - // regex that replaces a number surrounded by periods .0. with a number surrounded by brackets [0] const bracketRegex = /\.\d+\./g -// function that replaces any internal .0. with [0] -const bracketify = (str: string) => str.replace(bracketRegex, (match) => `[${match.slice(1, -1)}].`) +// function that replaces any internal .0. with [0]. to allow for terraform interpolation +const bracketifyTF = (str: string) => + str.replace(bracketRegex, (match) => `[${match.slice(1, -1)}].`) +const bracketify = (str: string) => str.replace(bracketRegex, (match) => `[${match.slice(1, -1)}]`) + /** * produces terraform string templates for exported (--> prefixed) values * recursively @@ -38,21 +22,31 @@ const exporter = ( ): NestedObject => Object.entries(obj).reduce((a, c) => { const [k, v] = c - if (isString(v) && v.startsWith('-->')) { - return { - ...a, - [k]: bracketify( - `\${${pivot}.${type}.${scoped}.${path.length ? path.join('.') + '.' : ''}${k}}` - ), + const accessPath = path.length ? path.join('.') + '.' : '' + const access = `\${${pivot}.${type}.${scoped}.${accessPath}${k}}` + const fixed = bracketifyTF(access) + const list = bracketify(`\${${pivot}.${type}.${scoped}.${accessPath}[\"${k}\"]}`) + if (isString(v)) { + if (v.startsWith('-->*')) { + return { ...a, [k]: list } + } else if (v.startsWith('-->')) { + return { ...a, [k]: fixed } + } else { + return { ...a, [k]: v } } } else if (isPlainObject(v)) { return { ...a, [k]: exporter(v, scoped, pivot, type, [...path, k]) } } else if (isArray(v)) { return { ...a, - [k]: v.map((x, i) => - isPlainObject(x) ? exporter(x, scoped, pivot, type, [...path, k, i]) : x - ), + [k]: v.map((x, i) => { + if (isString(x) && x.startsWith('-->')) { + return bracketify(`${access}[${i}]`) + } else if (isPlainObject(x)) { + return exporter(x, scoped, pivot, type, [...path, k, i]) + } + return x + }), } } else { //console.log(`passthrough in exporter function...`) @@ -61,6 +55,65 @@ const exporter = ( } }, {}) +/** + * recursive function that takes a path of strings or numbers + * and returns an object with nested objects and arrays + * + **/ +const pathObjectifier = (path: any[]) => { + const [head, ...tail] = path + if (tail && tail.length) { + if (isString(head)) return { [head]: pathObjectifier(tail) } + else { + // create an array of dummy objects leading up to the index + const dummyArray = (head && Array(head - 1).fill({})) || [] + + return [...dummyArray, pathObjectifier(tail)] + } + } else { + if (isString(head)) return { [head]: 'šŸ”„' } + else return [...Array(head).fill('...'), 'šŸ”„'] + } +} +/** + * cleans out any export-specific values (--> prefixed) recursively and warns + * the user if they forgot to export a value using the --> prefix + */ +const exportFinalizer = (obj: object, path): NestedObject => { + const warn = (path: string[]) => { + const reminder = '\nšŸ”„ Upstream export (-->) missing. Required by:' + console.warn(`${reminder}\n${JSON.stringify(pathObjectifier(path), null, 4)}`) + //console.log(JSON.stringify(path)) + } + return Object.entries(obj).reduce((a, c) => { + const [k, v] = c + if (v === '-->') return a + if (v === 'undefined' || v === 'null') warn([...path, k]) + if (isString(v) && v.startsWith('-->')) { + const cleaned = v.replace(/-->\*|-->/, '') + if (cleaned === '') { + return a + } else { + return { ...a, [k]: cleaned } + } + } else if (isPlainObject(v)) { + return { ...a, [k]: exportFinalizer(v, [...path, k]) } + } else if (isArray(v)) { + //console.log(`array found for ${k}: ${JSON.stringify(v)}`) + return { + ...a, + [k]: v.map((x, i) => { + if (x == 'undefined' || x == 'null') warn([...path, k, i]) + if (isPlainObject(x)) return exportFinalizer(x, [...path, k, i]) + else return x + }), + } + } else { + return { ...a, [k]: v } + } + }, {}) +} + /** * flattens modules into a single object, with unique keys created by * joining nested key identifiers until the function reaches a pivot point @@ -99,7 +152,7 @@ export const flattenPreservingPaths = ( ...a[key], [type]: { ...(a[key] && a[key][type]), - [scoped]: exportCleaner(target), + [scoped]: exportFinalizer(target, [key, raw_type]), }, }, } @@ -112,58 +165,6 @@ export const flattenPreservingPaths = ( }, acc) } -/** - * deep merges arbitrary number of objects into one - */ -const deepMerge = (...objs) => { - const result = {} - for (const obj of objs) { - for (const key in obj) { - const val = obj[key] - if (key === 'provider' && result[key] && 'alias' in val) { - continue - } - if (Array.isArray(val)) { - result[key] = result[key] || [] - result[key].push(...val) - } else if (typeof val === 'object') { - result[key] = deepMerge(result[key] || {}, val) - } else { - result[key] = val - } - } - } - return result -} - -export const parseFirstArgObj = (fn: any) => { - const args = fn.toString().match(/\(([^)]*)\)/)[1] - let obj - try { - // grab the first object using regex - obj = args.match(/{[\s\S]*(?=,\s{0,10}my)/)[0] - } catch (e) { - console.error(`Ensure the second argument to your module is named "my"`) - return {} - } - // replace = with : - const replaced = obj.replace(/ ?={1}/g, ':') - // count the number of opening { - const openers = replaced.match(/{/g) - // count the number of closing } - const closers = replaced.match(/}/g) - // if the number of opening { is greater than the number of closing } - // we need to add the difference to the end of the string - const diff = openers.length - closers.length - const add = Array(diff).fill('}') - const final = replaced + add.join('') - // replace single quotes with double quotes - const double = final.replace(/'/g, '"') - // wrap any symbol keys with quotes - const stringed = double.replace(/([a-zA-Z0-9|_]+?):/g, '"$1":') - return JSON.parse(stringed) -} - type FnParams any> = T extends (...args: infer P) => any ? P : never type FnReturn any> = T extends (...args: any[]) => infer R ? R : never @@ -183,17 +184,48 @@ export const modulate = any }>( ) => { const [key, fn] = Object.entries(obj)[0] - //const defaultArg = parseFirstArgObj(fn) - return (...args: [FnParams[0], ...Partial>[]]) => { const ref = { [key]: fn(...args) } const refs = flattenPreservingPaths(ref, provider, [], {}, true) - // TODO: consider just passing the same arguments to the reference function const obj = { [key]: fn(...args, refs) } const out = flattenPreservingPaths(obj, provider, [], {}, false) return [out, refs] as [FnReturn, FnReturn] } } + +const isEmpty = (x: any) => + isPlainObject(x) && !Object.keys(x).length ? true : isArray(x) && !x.length ? true : false + +/** + * deep merges arbitrary number of objects into one + */ +const deepMerge = (...objs) => { + const result = {} + for (const obj of objs) { + for (const key in obj) { + const val = obj[key] + if (key === 'provider' && result[key] && 'alias' in val) { + // don't duplicate providers + continue + } + if (Array.isArray(val)) { + // clean out arrays with empty contents + const filtered = val.filter((x) => !isEmpty(x)) + if (!filtered.length) continue + else { + result[key] = result[key] || [] + result[key].push(...filtered) + } + } else if (typeof val === 'object') { + result[key] = deepMerge(result[key] || {}, val) + } else { + result[key] = val + } + } + } + return result +} + export interface Provider { [key: string]: { region: string