Skip to content

Commit

Permalink
added functions and parameter types
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbarbour committed Oct 28, 2023
1 parent 949fabb commit 9a7318a
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 41 deletions.
28 changes: 23 additions & 5 deletions src/cloudformation/Value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ export type Base64Fn = {
export type Cidr = {
'Fn::Cidr': [Value<string>, Value<number>, Value<number>]
}
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<any> | ConditionFunction;
export type ConditionFunction = ConditionAnd | ConditionOr | ConditionEquals | ConditionNot | ConditionIf;

export type Instrinsic<T, S extends string> = Record<S, T>

Expand All @@ -33,13 +53,13 @@ export function sub<T extends string>(body: T, params: SubParams<T>): Sub {
"Fn::Sub": [body, params]
}
}
export function ref(logicalName: string): Ref {
export function ref<R extends string = string>(logicalName: R): { Ref: R } {
return {
Ref: logicalName
}
}

export function transform(part: Value<string>): Value<string> {
export function transform(part: Value<any>): Value<any> {
if(part instanceof AwsResource){
return ref(part.logicalName!);
}
Expand All @@ -54,6 +74,4 @@ export function joinWith(separator: string, ...parts: Value<string>[]): Join {
return {
'Fn::Join': [separator, bits]
}
}


}
13 changes: 5 additions & 8 deletions src/cloudformation/aws.ts
Original file line number Diff line number Diff line change
@@ -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<S extends AwsServices> = { [k in S]: Get<(typeof resources)['aws'][k]> } & BuiltIns

class Builder<AWS> {
constructor(private readonly aws: AWS) {
}
create<C extends string = string>(outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder<AWS, {}, C> {
return TemplateBuilder.create(this.aws, outputFileName, parametersFileName);
create(outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder<AWS, {}> {
return TemplateBuilder.create(this.aws as any, outputFileName, parametersFileName);
}
}

type Get<T extends {[key: string]: { load: () => Promise<any> }}> = {[K in keyof T]: T[K]['load'] extends () => Promise<infer R> ? R : never}
export type BuiltIns = {
logicalName(prefix: string): string;
customResource: typeof customResource
}
export type AWSResourcesFor<S extends AwsServices> = { [k in S]: Get<(typeof resources)['aws'][k]> } & BuiltIns

export class AwsLoader<AWS> {
aws = {};
Expand Down
2 changes: 0 additions & 2 deletions src/cloudformation/cloudformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`, '');
}
Expand Down
1 change: 1 addition & 0 deletions src/cloudformation/modules/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import {Authorizer} from "../../aws/apigateway/Authorizer";
import {BasePathMapping} from "../../aws/apigateway/BasePathMapping";
import {Deployment} from "../../aws/apigateway/Deployment";
Expand Down
41 changes: 37 additions & 4 deletions src/cloudformation/template/parameter.ts
Original file line number Diff line number Diff line change
@@ -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<AWS::EC2::AvailabilityZone::Name>' |
'List<AWS::EC2::Image::Id>' |
'List<AWS::EC2::Instance::Id>' |
'List<AWS::EC2::SecurityGroup::GroupName>' |
'List<AWS::EC2::SecurityGroup::Id>' |
'List<AWS::EC2::Subnet::Id>' |
'List<AWS::EC2::Volume::Id>' |
'List<AWS::EC2::VPC::Id>' |
'List<AWS::Route53::HostedZone::Id>';

export type SSMParameterTypes = 'AWS::SSM::Parameter::Name' |
'AWS::SSM::Parameter::Value<String>' |
'AWS::SSM::Parameter::Value<List<String>>' |
'AWS::SSM::Parameter::Value<CommaDelimitedList>' |
`AWS::SSM::Parameter::Value<${AWSParameterType}>` |
`AWS::SSM::Parameter::Value<List<${AWSParameterType}>>`;

export type Parameter = ({
type: string;
type: 'String' | 'Number' | 'List<Number>' | 'List<String>' | '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<T> = { [K in keyof T]: <R = string>() => Value<R> }
52 changes: 44 additions & 8 deletions src/cloudformation/template/template-builder.ts
Original file line number Diff line number Diff line change
@@ -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<T>(item: T): T {
Expand All @@ -16,16 +23,45 @@ export function keepCase<T>(item: T): T {
return item;
}

export class TemplateBuilder<AWS, P extends {[param: string]: Parameter}, C extends string = string> {
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<AWS, P extends {[param: string]: Parameter}, C extends Record<string, never> = {}> {
private parameters: {[param: string]: Parameter} = {};
private outputDir = '';
private conditions: Record<string, any> = {};
private _templateTransform: (template: CloudFormationTemplate) => string = template => JSON.stringify(template, null, 2);
private transforms: string[] = [];
constructor(private readonly aws: AWS, public outputFileName: string, public parameterFileName: string = "parameters.json") {}

withCondition<S extends string>(name: S, condition: any): TemplateBuilder<AWS, P, C | S> {
this.conditions[name] = condition;
withCondition<S extends string>(name: S, predicate: (args: {compare: ConditionalFunctions, params: Params<P>, condition: (name: keyof C) => Value<boolean>}) => ConditionalValue): TemplateBuilder<AWS, P, C & {[k in S]: never }> {
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<P>),
condition: (name: keyof C) => ({Condition: name} as any)
});
return this as any;
}

Expand Down Expand Up @@ -61,8 +97,8 @@ export class TemplateBuilder<AWS, P extends {[param: string]: Parameter}, C exte
return (this.outputDir ? this.outputDir + '/' : '') + file;
}

build(builder: BuilderWith<AWS, P, C>): {template: CloudFormationTemplate; outputs?: Outputs} {
return TemplateCreator.createWithParams<AWS, P, C>(this.aws, (this.parameters ?? {}) as any,
build(builder: BuilderWith<AWS, P, keyof C & string>): {template: CloudFormationTemplate; outputs?: Outputs} {
return TemplateCreator.createWithParams<AWS, P, any>(this.aws, (this.parameters ?? {}) as any,
this.conditions,
builder,
this.pathTo(this.outputFileName),
Expand All @@ -71,7 +107,7 @@ export class TemplateBuilder<AWS, P extends {[param: string]: Parameter}, C exte
this.transforms.length > 0 ? this.transforms : undefined);
}

static create<AWS, C extends string = string>(aws: AWS, outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder<AWS, {}, C> {
static create<AWS, C extends Record<string, never> = {}>(aws: AWS & BuiltIns, outputFileName = 'template.json', parametersFileName='parameters.json'): TemplateBuilder<AWS, {}, C> {
return new TemplateBuilder(aws, outputFileName, parametersFileName);
}
}
92 changes: 84 additions & 8 deletions src/cloudformation/template/template-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 string, P = {}> = S extends `${infer F}\${${infer N}}${infer Rest}` ? P & {[k in N]: Value<string>} & SubFor<Rest, P> : P

export type BuiltIns<Condition extends string = never> = {
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<T>(name: Value<string>): T;
select<T>(index: number, items: Value<T[]>): T;
getAzs(region?: Value<string>): Value<Value<string>[]>;
base64Encode(value: Value<string>): Value<string>;
cidr(ipBlock: Value<string>, count: Value<number>, cidrBits: Value<number>): Value<Value<string>[]>;
if<T>(predicate: ConditionalValue, ifTrue: ConditionalValue, ifFalse: ConditionalValue): Value<Exclude<T, undefined>>;
join(delimiter: string, ...parts: Value<string>[]): Value<string>;
length(items: Value<Value<any>[]>): Value<number>;
split(delimiter: string, source: Value<string>): Value<Value<string>[]>;
sub<S extends string>(substitutionString: S, replaceWith: SubFor<S>): Value<string>;
}
}
export type Builder<AWS> = (aws: AWS) => void | Outputs
export type BuilderWith<AWS, ParamType, C> = (aws: AWS, parameters: Params<ParamType>, conditional: <R>(condition: C, resource: R) => R) => void | Outputs
export type BuilderWith<AWS, ParamType, C extends string> = (aws: AWS & BuiltIns<C>, parameters: Params<ParamType>, conditional: <R>(condition: C, resource: R) => R) => void | Outputs

export class TemplateCreator {
private logicalNames: string[] = [];

resources: AwsResource<string, any, any>[] = [];

builtInFunctions(): BuiltIns<string> {
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<T>(name: Value<string>): T {
return {"Fn::ImportValue": name} as any;
},
select<T>(index: number, items: Value<T[]>): T {
return { "Fn::Select" : [ `${index}`, items ] } as any;
},
getAzs(region?: Value<string>): Value<Value<string>[]> {
return { "Fn::GetAZs": region ?? ref('AWS::Region') } as any;
},
base64Encode<T extends Value<string>>(value: T) {
return { "Fn::Base64" : value };
},
cidr(ipBlock: Value<string>, count: Value<number>, cidrBits: Value<number>) {
return { "Fn::Cidr" : [transform(ipBlock), transform(count), transform(cidrBits)]}
},
if<T>(predicate: ConditionalValue, ifTrue: ConditionalValue, ifFalse: ConditionalValue): Value<T> {
return ifCondition(predicate, transform(ifTrue), transform(ifFalse)) as any;
},
join(delimiter: string, ...parts): Value<string> {
return joinWith(delimiter, ...parts);
},
length(items: Value<Value<any>[]>): Value<number> {
return { "Fn::Length": transform(items) } as any;
},
split(delimiter: string, source: Value<string>): Value<Value<string>[]> {
return { "Fn::Split" : [ delimiter, transform(source) ] };
},
sub<S extends string>(substitutionString: S, replaceWith: SubFor<S>): Value<string> {
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));
Expand Down Expand Up @@ -59,27 +136,26 @@ export class TemplateCreator {
}


static createWithParams<AWS, T extends {[param: string]: Parameter}, C extends string = string>(
static createWithParams<AWS, T extends {[param: string]: Parameter}, C extends Record<string, never> = {}>(
aws: AWS,
parameters: T,
conditions: Record<C, any>,
builder: BuilderWith<AWS, T, C>,
conditions: Record<string, any>,
builder: BuilderWith<AWS, T, keyof C & string>,
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<T>)
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')
Expand Down
21 changes: 15 additions & 6 deletions test/deploy-test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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<AWS::EC2::KeyPair::KeyName>' },
})
.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})
})
})

0 comments on commit 9a7318a

Please sign in to comment.