From 9a7318a6f1602684aff0f63aaf3edd1b807b0beb Mon Sep 17 00:00:00 2001 From: Chris Barbour Date: Sat, 28 Oct 2023 22:43:28 +0100 Subject: [PATCH] added functions and parameter types --- src/cloudformation/Value.ts | 28 +++++- src/cloudformation/aws.ts | 13 +-- src/cloudformation/cloudformation.ts | 2 - src/cloudformation/modules/api.ts | 1 + src/cloudformation/template/parameter.ts | 41 ++++++++- .../template/template-builder.ts | 52 +++++++++-- .../template/template-creator.ts | 92 +++++++++++++++++-- test/deploy-test.ts | 21 +++-- 8 files changed, 209 insertions(+), 41 deletions(-) diff --git a/src/cloudformation/Value.ts b/src/cloudformation/Value.ts index 12b6f15..983cc9e 100644 --- a/src/cloudformation/Value.ts +++ b/src/cloudformation/Value.ts @@ -16,6 +16,26 @@ export type Base64Fn = { export type Cidr = { 'Fn::Cidr': [Value, Value, Value] } +export type Condition = { + Condition: string +} +export type ConditionAnd = { + 'Fn::And': [ConditionalValue, ConditionalValue] +} +export type ConditionOr = { + 'Fn::Or': [ConditionalValue, ConditionalValue] +} +export type ConditionEquals = { + 'Fn::Equals' : [ConditionalValue, ConditionalValue] +} +export type ConditionNot = { + 'Fn::Not' : [ConditionalValue] +} +export type ConditionIf = { + 'Fn::If' : [ConditionalValue, ConditionalValue, ConditionalValue] +} +export type ConditionalValue = Value | ConditionFunction; +export type ConditionFunction = ConditionAnd | ConditionOr | ConditionEquals | ConditionNot | ConditionIf; export type Instrinsic = Record @@ -33,13 +53,13 @@ export function sub(body: T, params: SubParams): Sub { "Fn::Sub": [body, params] } } -export function ref(logicalName: string): Ref { +export function ref(logicalName: R): { Ref: R } { return { Ref: logicalName } } -export function transform(part: Value): Value { +export function transform(part: Value): Value { if(part instanceof AwsResource){ return ref(part.logicalName!); } @@ -54,6 +74,4 @@ export function joinWith(separator: string, ...parts: Value[]): Join { return { 'Fn::Join': [separator, bits] } -} - - +} \ No newline at end of file diff --git a/src/cloudformation/aws.ts b/src/cloudformation/aws.ts index 3d80257..49a177b 100644 --- a/src/cloudformation/aws.ts +++ b/src/cloudformation/aws.ts @@ -1,24 +1,21 @@ import {resources} from './aws-resources'; -import { customResource } from './cloudformation'; +import { BuiltIns, customResource } from './cloudformation'; import { TemplateBuilder } from './template/template-builder'; type AwsServiceList = (typeof resources)['aws']; export type AwsServices = keyof AwsServiceList; +export type AWSResourcesFor = { [k in S]: Get<(typeof resources)['aws'][k]> } & BuiltIns + class Builder { constructor(private readonly aws: AWS) { } - create(outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder { - return TemplateBuilder.create(this.aws, outputFileName, parametersFileName); + create(outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder { + return TemplateBuilder.create(this.aws as any, outputFileName, parametersFileName); } } type Get Promise }}> = {[K in keyof T]: T[K]['load'] extends () => Promise ? R : never} -export type BuiltIns = { - logicalName(prefix: string): string; - customResource: typeof customResource -} -export type AWSResourcesFor = { [k in S]: Get<(typeof resources)['aws'][k]> } & BuiltIns export class AwsLoader { aws = {}; diff --git a/src/cloudformation/cloudformation.ts b/src/cloudformation/cloudformation.ts index bc3a762..037b0ce 100644 --- a/src/cloudformation/cloudformation.ts +++ b/src/cloudformation/cloudformation.ts @@ -18,8 +18,6 @@ export * from './template/outputs'; export * from './template/cloudformation-template'; export * from './template/parameter'; - - export function normalize(name: string) { return name.split(/[^a-zA-Z0-9]/g).reduce((prev, current) => `${prev}${current.substring(0,1).toUpperCase()}${current.substring(1)}`, ''); } diff --git a/src/cloudformation/modules/api.ts b/src/cloudformation/modules/api.ts index f9ff029..796c29d 100644 --- a/src/cloudformation/modules/api.ts +++ b/src/cloudformation/modules/api.ts @@ -1,3 +1,4 @@ + import {Authorizer} from "../../aws/apigateway/Authorizer"; import {BasePathMapping} from "../../aws/apigateway/BasePathMapping"; import {Deployment} from "../../aws/apigateway/Deployment"; diff --git a/src/cloudformation/template/parameter.ts b/src/cloudformation/template/parameter.ts index fc28143..a39848f 100644 --- a/src/cloudformation/template/parameter.ts +++ b/src/cloudformation/template/parameter.ts @@ -1,14 +1,47 @@ import { Value } from '../Value'; +export type AWSParameterType = 'AWS::EC2::AvailabilityZone::Name' | + 'AWS::EC2::Image::Id' | + 'AWS::EC2::Instance::Id' | + 'AWS::EC2::KeyPair::KeyName' | + 'AWS::EC2::SecurityGroup::GroupName' | + 'AWS::EC2::SecurityGroup::Id' | + 'AWS::EC2::Subnet::Id' | + 'AWS::EC2::Volume::Id' | + 'AWS::EC2::VPC::Id' | + 'AWS::Route53::HostedZone::Id' | + 'List' | + 'List' | + 'List' | + 'List' | + 'List' | + 'List' | + 'List' | + 'List' | + 'List'; + +export type SSMParameterTypes = 'AWS::SSM::Parameter::Name' | + 'AWS::SSM::Parameter::Value' | + 'AWS::SSM::Parameter::Value>' | + 'AWS::SSM::Parameter::Value' | + `AWS::SSM::Parameter::Value<${AWSParameterType}>` | + `AWS::SSM::Parameter::Value>`; + export type Parameter = ({ - type: string; + type: 'String' | 'Number' | 'List' | 'List' | 'CommaDelimitedList' | AWSParameterType | SSMParameterTypes; } | { type: 'Future'; environmentName: string; -}) & { +}) & { default?: string; - allowedValues? : string[]; - description? : string; + allowedValues?: string[]; + description?: string; + constraintDescription?: string; + noEcho?: boolean; + minLength?: number; + maxLength?: number; + maxValue?: number; + minValue?: number; } export type Params = { [K in keyof T]: () => Value } \ No newline at end of file diff --git a/src/cloudformation/template/template-builder.ts b/src/cloudformation/template/template-builder.ts index 331eb6a..f5cac18 100644 --- a/src/cloudformation/template/template-builder.ts +++ b/src/cloudformation/template/template-builder.ts @@ -1,8 +1,15 @@ import fs from 'fs'; +import { + ConditionalValue, + ConditionAnd, ConditionEquals, ConditionIf, + ConditionNot, + ConditionOr, + Value +} from '../Value'; import { CloudFormationTemplate } from './cloudformation-template'; import { Outputs } from './outputs'; -import { Parameter } from './parameter'; -import { BuilderWith, TemplateCreator } from './template-creator'; +import { Parameter, Params } from './parameter'; +import { BuilderWith, BuiltIns, TemplateCreator } from './template-creator'; export function keepCase(item: T): T { @@ -16,7 +23,32 @@ export function keepCase(item: T): T { return item; } -export class TemplateBuilder { +export function and(a: ConditionalValue, b: ConditionalValue): ConditionAnd { + return {"Fn::And": [a, b]}; +} +export function or(a: ConditionalValue, b: ConditionalValue): ConditionOr { + return {"Fn::Or": [a, b]}; +} +export function equals(a: ConditionalValue, b: ConditionalValue): ConditionEquals { + return {"Fn::Equals": [a, b]}; +} +export function not(a: ConditionalValue): ConditionNot { + return {"Fn::Not": [a]}; +} + +export function ifCondition(predicate: ConditionalValue, whenTrue: ConditionalValue, whenFalse: ConditionalValue): ConditionIf { + return {"Fn::If": [predicate, whenTrue, whenFalse]}; +} + +export type ConditionalFunctions = { + and: typeof and, + or: typeof or, + equals: typeof equals, + not: typeof not, + if: typeof ifCondition, +} + +export class TemplateBuilder = {}> { private parameters: {[param: string]: Parameter} = {}; private outputDir = ''; private conditions: Record = {}; @@ -24,8 +56,12 @@ export class TemplateBuilder(name: S, condition: any): TemplateBuilder { - this.conditions[name] = condition; + withCondition(name: S, predicate: (args: {compare: ConditionalFunctions, params: Params

, condition: (name: keyof C) => Value}) => ConditionalValue): TemplateBuilder { + this.conditions[name] = predicate({ + compare: {and, or, equals, not, if: ifCondition}, + params: Object.keys(this.parameters).reduce((prev, parameter) => ({...prev, [parameter]: () => ({'Ref': parameter})}), {} as Params

), + condition: (name: keyof C) => ({Condition: name} as any) + }); return this as any; } @@ -61,8 +97,8 @@ export class TemplateBuilder): {template: CloudFormationTemplate; outputs?: Outputs} { - return TemplateCreator.createWithParams(this.aws, (this.parameters ?? {}) as any, + build(builder: BuilderWith): {template: CloudFormationTemplate; outputs?: Outputs} { + return TemplateCreator.createWithParams(this.aws, (this.parameters ?? {}) as any, this.conditions, builder, this.pathTo(this.outputFileName), @@ -71,7 +107,7 @@ export class TemplateBuilder 0 ? this.transforms : undefined); } - static create(aws: AWS, outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder { + static create = {}>(aws: AWS & BuiltIns, outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder { return new TemplateBuilder(aws, outputFileName, parametersFileName); } } diff --git a/src/cloudformation/template/template-creator.ts b/src/cloudformation/template/template-creator.ts index 4df8a96..3f8e30f 100644 --- a/src/cloudformation/template/template-creator.ts +++ b/src/cloudformation/template/template-creator.ts @@ -2,19 +2,96 @@ import fs from 'fs'; import { PathLike } from 'fs'; import { customResource } from '../modules/custom-resource'; import { AwsResource } from '../resources/AwsResource'; -import { transform } from '../Value'; +import { ConditionalValue, joinWith, ref, transform, Value } from '../Value'; import { CloudFormationTemplate } from './cloudformation-template'; import { Outputs } from './outputs'; import { Parameter, Params } from './parameter'; +import { ifCondition } from './template-builder'; +export type SubFor = S extends `${infer F}\${${infer N}}${infer Rest}` ? P & {[k in N]: Value} & SubFor : P + +export type BuiltIns = { + logicalName(prefix: string): string; + customResource: typeof customResource + accountId: { Ref: 'AWS::AccountId' } + notificationArns: { Ref: 'AWS::NotificationARNs' } + noValue: { Ref: 'AWS::NoValue' } + region: { Ref: 'AWS::Region' } + stackId: { Ref: 'AWS::StackId' } + stackName: { Ref: 'AWS::StackName' } + urlSuffix: { Ref: 'AWS::URLSuffix' } + condition(name: Condition): ConditionalValue; + functions: { + import(name: Value): T; + select(index: number, items: Value): T; + getAzs(region?: Value): Value[]>; + base64Encode(value: Value): Value; + cidr(ipBlock: Value, count: Value, cidrBits: Value): Value[]>; + if(predicate: ConditionalValue, ifTrue: ConditionalValue, ifFalse: ConditionalValue): Value>; + join(delimiter: string, ...parts: Value[]): Value; + length(items: Value[]>): Value; + split(delimiter: string, source: Value): Value[]>; + sub(substitutionString: S, replaceWith: SubFor): Value; + } +} export type Builder = (aws: AWS) => void | Outputs -export type BuilderWith = (aws: AWS, parameters: Params, conditional: (condition: C, resource: R) => R) => void | Outputs +export type BuilderWith = (aws: AWS & BuiltIns, parameters: Params, conditional: (condition: C, resource: R) => R) => void | Outputs export class TemplateCreator { private logicalNames: string[] = []; resources: AwsResource[] = []; + builtInFunctions(): BuiltIns { + const logicalName = (prefix: string) => this.logicalName(prefix); + return { + logicalName, + customResource, + accountId: ref('AWS::AccountId'), + notificationArns: ref('AWS::NotificationARNs'), + noValue: ref('AWS::NoValue'), + region: ref('AWS::Region'), + stackId: ref('AWS::StackId'), + stackName: ref('AWS::StackName'), + urlSuffix: ref('AWS::URLSuffix'), + condition(name: string): ConditionalValue { + return { 'Condition': name }; + }, + functions: { + import(name: Value): T { + return {"Fn::ImportValue": name} as any; + }, + select(index: number, items: Value): T { + return { "Fn::Select" : [ `${index}`, items ] } as any; + }, + getAzs(region?: Value): Value[]> { + return { "Fn::GetAZs": region ?? ref('AWS::Region') } as any; + }, + base64Encode>(value: T) { + return { "Fn::Base64" : value }; + }, + cidr(ipBlock: Value, count: Value, cidrBits: Value) { + return { "Fn::Cidr" : [transform(ipBlock), transform(count), transform(cidrBits)]} + }, + if(predicate: ConditionalValue, ifTrue: ConditionalValue, ifFalse: ConditionalValue): Value { + return ifCondition(predicate, transform(ifTrue), transform(ifFalse)) as any; + }, + join(delimiter: string, ...parts): Value { + return joinWith(delimiter, ...parts); + }, + length(items: Value[]>): Value { + return { "Fn::Length": transform(items) } as any; + }, + split(delimiter: string, source: Value): Value[]> { + return { "Fn::Split" : [ delimiter, transform(source) ] }; + }, + sub(substitutionString: S, replaceWith: SubFor): Value { + return { "Fn::Sub": [ substitutionString, Object.keys(replaceWith).reduce((prev, next) => ({...prev, [next]: transform(replaceWith[next])}), {_nocaps: true} as any)] } + } + } + } + } + private logicalName(prefix: string): string { const _prefix = prefix.substring(0, 60) const prefixed = this.logicalNames.filter(it => it.startsWith(_prefix)); @@ -59,27 +136,26 @@ export class TemplateCreator { } - static createWithParams( + static createWithParams = {}>( aws: AWS, parameters: T, - conditions: Record, - builder: BuilderWith, + conditions: Record, + builder: BuilderWith, file: PathLike = "template.json", paramsFile: PathLike = "parameters.json", templateTransform: (template: CloudFormationTemplate) => string = template => JSON.stringify(template, null, 2), transForms?: string[] ): {template: CloudFormationTemplate; outputs?: Outputs} { const template = new TemplateCreator(); - const logicalNameFunction = (prefix: string) => template.logicalName(prefix); const builderAws = Object.keys(aws as any).reduce((prev1, next1) => ({ ...prev1, [next1]: Object.keys(aws[next1]) .filter(name => name !== 'logicalName') .reduce((prev, key) => ({...prev, [key]: template.modify(aws[next1][key])}), {}) }) - , {logicalName: logicalNameFunction} as any) + , {} as any) const parameterFunctions = Object.keys(parameters).reduce((prev, parameter) => ({...prev, [parameter]: () => ({'Ref': parameter})}), {} as Params) - const outputs = builder({...builderAws, customResource: template.modify(customResource)}, parameterFunctions, ((condition, resource) => { (resource as any)._condition = condition; return resource; })); + const outputs = builder({...builderAws, ...template.builtInFunctions()}, parameterFunctions, ((condition, resource) => { (resource as any)._condition = condition; return resource; })); const envParams = Object.keys(parameters).reduce((prev, param) => { const parameter = parameters[param] as any; if(parameter.type === 'Future') diff --git a/test/deploy-test.ts b/test/deploy-test.ts index 51aab3f..08c246f 100644 --- a/test/deploy-test.ts +++ b/test/deploy-test.ts @@ -1,4 +1,4 @@ -import { AwsLoader, stackOutput, join } from '../src/cloudformation/cloudformation'; +import { AwsLoader } from '../src/cloudformation/cloudformation'; // export default Template.createWithParams({ // ABC: fromEnv('GHJ') @@ -13,10 +13,19 @@ const template = await AwsLoader.register('s3').load(); export default template .create() .params({ - MyBucketInStackA: { type: 'String' } + MyBucketInStackA: { type: 'String' }, + Enabled: { type: 'AWS::SSM::Parameter::Value' }, }) - .build((aws, params) => { - aws.s3.bucket({ - bucketName: join(params.MyBucketInStackA(), '-contrived-example') - }); + .withCondition('abc', () => true) + .withCondition( + 'xyz', ({compare, params, condition}) => + compare.and(condition('abc'), params.Enabled()) + ) + .build((aws, params, conditional) => { + conditional('xyz', aws.s3.bucket({ + bucketName: aws.functions.if(aws.condition('abc'), aws.functions.base64Encode(params.MyBucketInStackA()), 'no name'), + })); + aws.s3.bucket({ + bucketName: aws.functions.sub('${u}asfsdf${xyz}adf${x}sdf${q}', {xyz: '', x: '', q: '', u: aws.noValue}) + }) }) \ No newline at end of file