Implementing Custom Stacks

You can create your own custom stacks with TypeScript by implementing CustomStackHandler interface. Let’s walk through how this works in practice by implementing a custom stack handler that we use to deploy CloudFormation stacks 😀.

Custom Stack State

First, you need to decide what information you want to expose in your custom stack's state. The only requirement is that the state object must implement CustomStackState interface.

Create my-custom-stack.ts file and place it in the src directory of your Takomo project. Add the following content into the file.

src/my-custom-stack.ts
1import { CustomStackState } from 'takomo';
2
3type MyCustomStackState = CustomStackState & {
4  // Here we can add more state information later
5};

Custom Stack Configuration

Next, you need to decide what information you want to pass for your custom stack handler from stack configuration files. As we are building a handler for CloudFormation, we propably want to be able to pass stack template.

src/my-custom-stack.ts
1import { CustomStackState } from 'takomo';
2
3type MyCustomStackState = CustomStackState & {
4  // Here we can add more state information later
5};
6
7type MyCustomStackConfig = {
8  stackTemplate: string;
9};

Custom Stack Type

Each custom stack handler needs to have a unique type which you can reference from stack configuration files.

src/my-custom-stack.ts
1import { CustomStackState, CustomStackHandler } from 'takomo';
2
3type MyCustomStackState = CustomStackState & {
4  // Here we can add more state information later
5};
6
7type MyCustomStackConfig = {
8  stackTemplate: string;
9};
10
11export const myCustomStackHandler: CustomStackHandler<
12  MyCustomStackConfig,
13  MyCustomStackState
14> = {
15  type: 'my-custom-stack',
16};

Parsing Custom Stack Configuration

It's the responsibility of a custom stack handler to parse and validate its own configuration the user specifies in stack configuration files. Implement parseConfig function.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6} from 'takomo';
7
8type MyCustomStackState = CustomStackState & {
9  // Here we can add more state information later
10};
11
12type MyCustomStackConfig = {
13  stackTemplate: string;
14};
15
16export const myCustomStackHandler: CustomStackHandler<
17  MyCustomStackConfig,
18  MyCustomStackState
19> = {
20  type: 'my-custom-stack',
21  parseConfig: async ({
22    rawConfig,
23  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
24    const configObj = rawConfig as Record<string, unknown>;
25
26    // Validate that stackTemplate property is defined
27    if (configObj.stackTemplate === undefined) {
28      return {
29        success: false,
30        message: 'stackTemplate is required in custom stack config',
31      };
32    }
33
34    // Validate that stackTemplate is of type string
35    if (typeof configObj.stackTemplate !== 'string') {
36      return {
37        success: false,
38        message: 'stackTemplate is must be string in custom stack config',
39      };
40    }
41
42    return {
43      success: true,
44      parsedConfig: configObj as MyCustomStackConfig,
45    };
46  },
47};

Get Current State

You then need to implement returning of the current state of your custom stack. In this example, we query stack's state using AWS SDK.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6  GetCurrentStateProps,
7  GetCurrentStateResult,
8} from 'takomo';
9import {
10  CloudFormationClient,
11  DescribeStacksCommand,
12  Stack,
13} from '@aws-sdk/client-cloudformation';
14
15type MyCustomStackState = CustomStackState & {
16  // Here we can add more state information later
17};
18
19type MyCustomStackConfig = {
20  stackTemplate: string;
21};
22
23// Utility function to convert an array to record
24const arrayToRecord = <T extends object, K extends string | number | symbol, V>(
25  array: ReadonlyArray<T>,
26  keyExtractor: (item: T) => K,
27  valueExtractor: (item: T) => V,
28): Record<K, V> =>
29  array.reduce(
30    (collected, item) => ({
31      ...collected,
32      [keyExtractor(item)]: valueExtractor(item),
33    }),
34    {} as Record<K, V>,
35  );
36
37// Utility function to convert a CloudFormation stack to a CustomStackState
38const convertToCustomStackState = (stack?: Stack): CustomStackState => {
39  if (!stack) {
40    return { status: 'PENDING' };
41  }
42
43  const outputs = arrayToRecord(
44    stack.Outputs ?? [],
45    (output) => output.OutputKey!,
46    (output) => output.OutputValue!,
47  );
48
49  const tags = arrayToRecord(
50    stack.Tags ?? [],
51    (tag) => tag.Key!,
52    (tag) => tag.Value!,
53  );
54
55  const parameters = arrayToRecord(
56    stack.Parameters ?? [],
57    (param) => param.ParameterKey!,
58    (param) => param.ParameterValue!,
59  );
60
61  return { status: 'CREATE_COMPLETE', outputs, tags, parameters };
62};
63
64// Utility function to describe a CloudFormation stack
65const describeStack = async (
66  client: CloudFormationClient,
67  stackName: string,
68): Promise<CustomStackState | undefined> => {
69  try {
70    const { Stacks = [] } = await client.send(
71      new DescribeStacksCommand({ StackName: stackName }),
72    );
73
74    if (Stacks.length === 0) {
75      return undefined;
76    }
77
78    return convertToCustomStackState(Stacks[0]);
79  } catch (e) {
80    const error = e as any;
81    if (error.Code === 'ValidationError') {
82      if (error.message === `Stack with id ${stackName} does not exist`) {
83        return undefined;
84      }
85    }
86
87    throw e;
88  }
89};
90
91export const myCustomStackHandler: CustomStackHandler<
92  MyCustomStackConfig,
93  MyCustomStackState
94> = {
95  type: 'my-custom-stack',
96  parseConfig: async ({
97    rawConfig,
98  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
99    const configObj = rawConfig as Record<string, unknown>;
100
101    // Validate that stackTemplate property is defined
102    if (configObj.stackTemplate === undefined) {
103      return {
104        success: false,
105        message: 'stackTemplate is required in custom stack config',
106      };
107    }
108
109    // Validate that stackTemplate is of type string
110    if (typeof configObj.stackTemplate !== 'string') {
111      return {
112        success: false,
113        message: 'stackTemplate is must be string in custom stack config',
114      };
115    }
116
117    return {
118      success: true,
119      parsedConfig: configObj as MyCustomStackConfig,
120    };
121  },
122  getCurrentState: async ({
123    stack,
124  }: GetCurrentStateProps<TestingCustomStackHandlerConfig>): Promise<
125    GetCurrentStateResult<CustomStackState>
126  > => {
127    const credentials = await stack.getCredentials();
128
129    const client = new CloudFormationClient({
130      credentials,
131      region: stack.region,
132    });
133
134    try {
135      const currentState = await describeStack(client, stack.name);
136
137      if (currentState) {
138        return {
139          currentState,
140          success: true,
141        };
142      }
143      return { currentState: { status: 'PENDING' }, success: true };
144    } catch (e) {
145      const error = e as any;
146      if (error.Code === 'ValidationError') {
147        if (error.message === `Stack with id ${stack.name} does not exist`) {
148          return { currentState: { status: 'PENDING' }, success: true };
149        }
150      }
151
152      return { success: false, error, message: error.message };
153    }
154  },
155};

Get Changes

Next, we implement a function getChanges() to get expected changes to resources that are being updated. Implementing this function is not mandatory but if you don't do it, Takomo will fallback to default behavior and assume that there will always be changes. In this example, our implementation is simplified.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6  GetCurrentStateProps,
7  GetCurrentStateResult,
8  GetChangesProps,
9  GetChangesResult,
10} from 'takomo';
11import {
12  CloudFormationClient,
13  DescribeStacksCommand,
14  Stack,
15} from '@aws-sdk/client-cloudformation';
16
17type MyCustomStackState = CustomStackState & {
18  // Here we can add more state information later
19};
20
21type MyCustomStackConfig = {
22  stackTemplate: string;
23};
24
25// Utility function to convert an array to record
26const arrayToRecord = <T extends object, K extends string | number | symbol, V>(
27  array: ReadonlyArray<T>,
28  keyExtractor: (item: T) => K,
29  valueExtractor: (item: T) => V,
30): Record<K, V> =>
31  array.reduce(
32    (collected, item) => ({
33      ...collected,
34      [keyExtractor(item)]: valueExtractor(item),
35    }),
36    {} as Record<K, V>,
37  );
38
39// Utility function to convert a CloudFormation stack to a CustomStackState
40const convertToCustomStackState = (stack?: Stack): CustomStackState => {
41  if (!stack) {
42    return { status: 'PENDING' };
43  }
44
45  const outputs = arrayToRecord(
46    stack.Outputs ?? [],
47    (output) => output.OutputKey!,
48    (output) => output.OutputValue!,
49  );
50
51  const tags = arrayToRecord(
52    stack.Tags ?? [],
53    (tag) => tag.Key!,
54    (tag) => tag.Value!,
55  );
56
57  const parameters = arrayToRecord(
58    stack.Parameters ?? [],
59    (param) => param.ParameterKey!,
60    (param) => param.ParameterValue!,
61  );
62
63  return { status: 'CREATE_COMPLETE', outputs, tags, parameters };
64};
65
66// Utility function to describe a CloudFormation stack
67const describeStack = async (
68  client: CloudFormationClient,
69  stackName: string,
70): Promise<CustomStackState | undefined> => {
71  try {
72    const { Stacks = [] } = await client.send(
73      new DescribeStacksCommand({ StackName: stackName }),
74    );
75
76    if (Stacks.length === 0) {
77      return undefined;
78    }
79
80    return convertToCustomStackState(Stacks[0]);
81  } catch (e) {
82    const error = e as any;
83    if (error.Code === 'ValidationError') {
84      if (error.message === `Stack with id ${stackName} does not exist`) {
85        return undefined;
86      }
87    }
88
89    throw e;
90  }
91};
92
93export const myCustomStackHandler: CustomStackHandler<
94  MyCustomStackConfig,
95  MyCustomStackState
96> = {
97  type: 'my-custom-stack',
98  parseConfig: async ({
99    rawConfig,
100  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
101    const configObj = rawConfig as Record<string, unknown>;
102
103    // Validate that stackTemplate property is defined
104    if (configObj.stackTemplate === undefined) {
105      return {
106        success: false,
107        message: 'stackTemplate is required in custom stack config',
108      };
109    }
110
111    // Validate that stackTemplate is of type string
112    if (typeof configObj.stackTemplate !== 'string') {
113      return {
114        success: false,
115        message: 'stackTemplate is must be string in custom stack config',
116      };
117    }
118
119    return {
120      success: true,
121      parsedConfig: configObj as MyCustomStackConfig,
122    };
123  },
124  getCurrentState: async ({
125    stack,
126  }: GetCurrentStateProps<TestingCustomStackHandlerConfig>): Promise<
127    GetCurrentStateResult<CustomStackState>
128  > => {
129    const credentials = await stack.getCredentials();
130
131    const client = new CloudFormationClient({
132      credentials,
133      region: stack.region,
134    });
135
136    try {
137      const currentState = await describeStack(client, stack.name);
138
139      if (currentState) {
140        return {
141          currentState,
142          success: true,
143        };
144      }
145      return { currentState: { status: 'PENDING' }, success: true };
146    } catch (e) {
147      const error = e as any;
148      if (error.Code === 'ValidationError') {
149        if (error.message === `Stack with id ${stack.name} does not exist`) {
150          return { currentState: { status: 'PENDING' }, success: true };
151        }
152      }
153
154      return { success: false, error, message: error.message };
155    }
156  },
157  getChanges: async ({
158    stack,
159  }: GetChangesProps<
160    MyCustomStackConfig,
161    MyCustomStackState
162  >): Promise<GetChangesResult> => {
163    const credentials = await stack.getCredentials();
164
165    const client = new CloudFormationClient({
166      credentials,
167      region: stack.region,
168    });
169
170    try {
171      const state = await describeStack(client, stack.name);
172
173      if (state) {
174        return {
175          changes: [{ description: 'Stack exists and will be updated' }],
176          success: true,
177        };
178      }
179
180      return {
181        changes: [{ description: 'Stack does not exist and will be created' }],
182        success: true,
183      };
184    } catch (e) {
185      const error = e as any;
186      return { success: false, error, message: error.message };
187    }
188  },
189};

Delete Stack

Alright, now that we got all boilerplate in place, we can implement the actual logic to create, update and delete custom stacks. We start by implementing delete() function.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6  GetCurrentStateProps,
7  GetCurrentStateResult,
8  GetChangesProps,
9  GetChangesResult,
10  DeleteCustomStackProps,
11  DeleteCustomStackResult,
12} from 'takomo';
13import {
14  CloudFormationClient,
15  DescribeStacksCommand,
16  Stack,
17  DeleteStackCommand,
18  waitUntilStackDeleteComplete,
19} from '@aws-sdk/client-cloudformation';
20
21type MyCustomStackState = CustomStackState & {
22  // Here we can add more state information later
23};
24
25type MyCustomStackConfig = {
26  stackTemplate: string;
27};
28
29// Utility function to convert an array to record
30const arrayToRecord = <T extends object, K extends string | number | symbol, V>(
31  array: ReadonlyArray<T>,
32  keyExtractor: (item: T) => K,
33  valueExtractor: (item: T) => V,
34): Record<K, V> =>
35  array.reduce(
36    (collected, item) => ({
37      ...collected,
38      [keyExtractor(item)]: valueExtractor(item),
39    }),
40    {} as Record<K, V>,
41  );
42
43// Utility function to convert a CloudFormation stack to a CustomStackState
44const convertToCustomStackState = (stack?: Stack): CustomStackState => {
45  if (!stack) {
46    return { status: 'PENDING' };
47  }
48
49  const outputs = arrayToRecord(
50    stack.Outputs ?? [],
51    (output) => output.OutputKey!,
52    (output) => output.OutputValue!,
53  );
54
55  const tags = arrayToRecord(
56    stack.Tags ?? [],
57    (tag) => tag.Key!,
58    (tag) => tag.Value!,
59  );
60
61  const parameters = arrayToRecord(
62    stack.Parameters ?? [],
63    (param) => param.ParameterKey!,
64    (param) => param.ParameterValue!,
65  );
66
67  return { status: 'CREATE_COMPLETE', outputs, tags, parameters };
68};
69
70// Utility function to describe a CloudFormation stack
71const describeStack = async (
72  client: CloudFormationClient,
73  stackName: string,
74): Promise<CustomStackState | undefined> => {
75  try {
76    const { Stacks = [] } = await client.send(
77      new DescribeStacksCommand({ StackName: stackName }),
78    );
79
80    if (Stacks.length === 0) {
81      return undefined;
82    }
83
84    return convertToCustomStackState(Stacks[0]);
85  } catch (e) {
86    const error = e as any;
87    if (error.Code === 'ValidationError') {
88      if (error.message === `Stack with id ${stackName} does not exist`) {
89        return undefined;
90      }
91    }
92
93    throw e;
94  }
95};
96
97export const myCustomStackHandler: CustomStackHandler<
98  MyCustomStackConfig,
99  MyCustomStackState
100> = {
101  type: 'my-custom-stack',
102  parseConfig: async ({
103    rawConfig,
104  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
105    const configObj = rawConfig as Record<string, unknown>;
106
107    // Validate that stackTemplate property is defined
108    if (configObj.stackTemplate === undefined) {
109      return {
110        success: false,
111        message: 'stackTemplate is required in custom stack config',
112      };
113    }
114
115    // Validate that stackTemplate is of type string
116    if (typeof configObj.stackTemplate !== 'string') {
117      return {
118        success: false,
119        message: 'stackTemplate is must be string in custom stack config',
120      };
121    }
122
123    return {
124      success: true,
125      parsedConfig: configObj as MyCustomStackConfig,
126    };
127  },
128  getCurrentState: async ({
129    stack,
130  }: GetCurrentStateProps<TestingCustomStackHandlerConfig>): Promise<
131    GetCurrentStateResult<CustomStackState>
132  > => {
133    const credentials = await stack.getCredentials();
134
135    const client = new CloudFormationClient({
136      credentials,
137      region: stack.region,
138    });
139
140    try {
141      const currentState = await describeStack(client, stack.name);
142
143      if (currentState) {
144        return {
145          currentState,
146          success: true,
147        };
148      }
149      return { currentState: { status: 'PENDING' }, success: true };
150    } catch (e) {
151      const error = e as any;
152      if (error.Code === 'ValidationError') {
153        if (error.message === `Stack with id ${stack.name} does not exist`) {
154          return { currentState: { status: 'PENDING' }, success: true };
155        }
156      }
157
158      return { success: false, error, message: error.message };
159    }
160  },
161  getChanges: async ({
162    stack,
163  }: GetChangesProps<
164    MyCustomStackConfig,
165    MyCustomStackState
166  >): Promise<GetChangesResult> => {
167    const credentials = await stack.getCredentials();
168
169    const client = new CloudFormationClient({
170      credentials,
171      region: stack.region,
172    });
173
174    try {
175      const state = await describeStack(client, stack.name);
176
177      if (state) {
178        return {
179          changes: [{ description: 'Stack exists and will be updated' }],
180          success: true,
181        };
182      }
183
184      return {
185        changes: [{ description: 'Stack does not exist and will be created' }],
186        success: true,
187      };
188    } catch (e) {
189      const error = e as any;
190      return { success: false, error, message: error.message };
191    }
192  },
193  delete: async ({
194    stack,
195    logger,
196  }: DeleteCustomStackProps<
197    TestingCustomStackHandlerConfig,
198    CustomStackState
199  >): Promise<DeleteCustomStackResult> => {
200    logger.info(`About to delete custom stack '${stack.name}'`);
201    const credentials = await stack.getCredentials();
202
203    const client = new CloudFormationClient({
204      credentials,
205      region: stack.region,
206    });
207
208    try {
209      logger.info(`Initiating stack deletion`);
210      await client.send(new DeleteStackCommand({ StackName: stack.name }));
211
212      logger.info(`Waiting for stack deletion to complete`);
213      await waitUntilStackDeleteComplete(
214        { client, maxWaitTime: 2 * 60 * 1000 },
215        { StackName: stack.name },
216      );
217
218      logger.info(`Stack deletion complete`);
219
220      return {
221        success: true,
222      };
223    } catch (e) {
224      const error = e as any;
225      return { success: false, message: error.message, error };
226    }
227  },
228};

Create Stack

Next, we implement create() function that takes care creating custom stacks.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6  GetCurrentStateProps,
7  GetCurrentStateResult,
8  GetChangesProps,
9  GetChangesResult,
10  DeleteCustomStackProps,
11  DeleteCustomStackResult,
12  CreateCustomStackProps,
13  CreateCustomStackResult,
14} from 'takomo';
15import {
16  CloudFormationClient,
17  DescribeStacksCommand,
18  Stack,
19  DeleteStackCommand,
20  waitUntilStackDeleteComplete,
21  CreateStackCommand,
22  waitUntilStackCreateComplete,
23} from '@aws-sdk/client-cloudformation';
24
25type MyCustomStackState = CustomStackState & {
26  // Here we can add more state information later
27};
28
29type MyCustomStackConfig = {
30  stackTemplate: string;
31};
32
33// Utility function to convert an array to record
34const arrayToRecord = <T extends object, K extends string | number | symbol, V>(
35  array: ReadonlyArray<T>,
36  keyExtractor: (item: T) => K,
37  valueExtractor: (item: T) => V,
38): Record<K, V> =>
39  array.reduce(
40    (collected, item) => ({
41      ...collected,
42      [keyExtractor(item)]: valueExtractor(item),
43    }),
44    {} as Record<K, V>,
45  );
46
47// Utility function to convert a CloudFormation stack to a CustomStackState
48const convertToCustomStackState = (stack?: Stack): CustomStackState => {
49  if (!stack) {
50    return { status: 'PENDING' };
51  }
52
53  const outputs = arrayToRecord(
54    stack.Outputs ?? [],
55    (output) => output.OutputKey!,
56    (output) => output.OutputValue!,
57  );
58
59  const tags = arrayToRecord(
60    stack.Tags ?? [],
61    (tag) => tag.Key!,
62    (tag) => tag.Value!,
63  );
64
65  const parameters = arrayToRecord(
66    stack.Parameters ?? [],
67    (param) => param.ParameterKey!,
68    (param) => param.ParameterValue!,
69  );
70
71  return { status: 'CREATE_COMPLETE', outputs, tags, parameters };
72};
73
74// Utility function to describe a CloudFormation stack
75const describeStack = async (
76  client: CloudFormationClient,
77  stackName: string,
78): Promise<CustomStackState | undefined> => {
79  try {
80    const { Stacks = [] } = await client.send(
81      new DescribeStacksCommand({ StackName: stackName }),
82    );
83
84    if (Stacks.length === 0) {
85      return undefined;
86    }
87
88    return convertToCustomStackState(Stacks[0]);
89  } catch (e) {
90    const error = e as any;
91    if (error.Code === 'ValidationError') {
92      if (error.message === `Stack with id ${stackName} does not exist`) {
93        return undefined;
94      }
95    }
96
97    throw e;
98  }
99};
100
101export const myCustomStackHandler: CustomStackHandler<
102  MyCustomStackConfig,
103  MyCustomStackState
104> = {
105  type: 'my-custom-stack',
106  parseConfig: async ({
107    rawConfig,
108  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
109    const configObj = rawConfig as Record<string, unknown>;
110
111    // Validate that stackTemplate property is defined
112    if (configObj.stackTemplate === undefined) {
113      return {
114        success: false,
115        message: 'stackTemplate is required in custom stack config',
116      };
117    }
118
119    // Validate that stackTemplate is of type string
120    if (typeof configObj.stackTemplate !== 'string') {
121      return {
122        success: false,
123        message: 'stackTemplate is must be string in custom stack config',
124      };
125    }
126
127    return {
128      success: true,
129      parsedConfig: configObj as MyCustomStackConfig,
130    };
131  },
132  getCurrentState: async ({
133    stack,
134  }: GetCurrentStateProps<TestingCustomStackHandlerConfig>): Promise<
135    GetCurrentStateResult<CustomStackState>
136  > => {
137    const credentials = await stack.getCredentials();
138
139    const client = new CloudFormationClient({
140      credentials,
141      region: stack.region,
142    });
143
144    try {
145      const currentState = await describeStack(client, stack.name);
146
147      if (currentState) {
148        return {
149          currentState,
150          success: true,
151        };
152      }
153      return { currentState: { status: 'PENDING' }, success: true };
154    } catch (e) {
155      const error = e as any;
156      if (error.Code === 'ValidationError') {
157        if (error.message === `Stack with id ${stack.name} does not exist`) {
158          return { currentState: { status: 'PENDING' }, success: true };
159        }
160      }
161
162      return { success: false, error, message: error.message };
163    }
164  },
165  getChanges: async ({
166    stack,
167  }: GetChangesProps<
168    MyCustomStackConfig,
169    MyCustomStackState
170  >): Promise<GetChangesResult> => {
171    const credentials = await stack.getCredentials();
172
173    const client = new CloudFormationClient({
174      credentials,
175      region: stack.region,
176    });
177
178    try {
179      const state = await describeStack(client, stack.name);
180
181      if (state) {
182        return {
183          changes: [{ description: 'Stack exists and will be updated' }],
184          success: true,
185        };
186      }
187
188      return {
189        changes: [{ description: 'Stack does not exist and will be created' }],
190        success: true,
191      };
192    } catch (e) {
193      const error = e as any;
194      return { success: false, error, message: error.message };
195    }
196  },
197  delete: async ({
198    stack,
199    logger,
200  }: DeleteCustomStackProps<
201    TestingCustomStackHandlerConfig,
202    CustomStackState
203  >): Promise<DeleteCustomStackResult> => {
204    logger.info(`About to delete custom stack '${stack.name}'`);
205    const credentials = await stack.getCredentials();
206
207    const client = new CloudFormationClient({
208      credentials,
209      region: stack.region,
210    });
211
212    try {
213      logger.info(`Initiating stack deletion`);
214      await client.send(new DeleteStackCommand({ StackName: stack.name }));
215
216      logger.info(`Waiting for stack deletion to complete`);
217      await waitUntilStackDeleteComplete(
218        { client, maxWaitTime: 2 * 60 * 1000 },
219        { StackName: stack.name },
220      );
221
222      logger.info(`Stack deletion complete`);
223
224      return {
225        success: true,
226      };
227    } catch (e) {
228      const error = e as any;
229      return { success: false, message: error.message, error };
230    }
231  },
232  create: async ({
233    stack,
234    config,
235    tags,
236    parameters,
237    logger,
238  }: CreateCustomStackProps<TestingCustomStackHandlerConfig>): Promise<
239    CreateCustomStackResult<CustomStackState>
240  > => {
241    logger.info(`About to create custom stack '${stack.name}'`);
242
243    const credentials = await stack.getCredentials();
244
245    const client = new CloudFormationClient({
246      credentials,
247      region: stack.region,
248    });
249
250    try {
251      logger.info(`Initiating stack creation`);
252      await client.send(
253        new CreateStackCommand({
254          StackName: stack.name,
255          TemplateBody: config.stackTemplate,
256          Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
257          Parameters: Object.entries(parameters).map(
258            ([ParameterKey, ParameterValue]) => ({
259              ParameterKey,
260              ParameterValue,
261            }),
262          ),
263        }),
264      );
265      logger.info(`Waiting for stack creation to complete`);
266      await waitUntilStackCreateComplete(
267        { client, maxWaitTime: 3 * 60 * 1000 },
268        { StackName: stack.name },
269      );
270
271      logger.info(`Stack creation complete`);
272      const createdState = await describeStack(client, stack.name);
273
274      if (createdState) {
275        return {
276          createdState,
277          success: true,
278        };
279      }
280
281      throw new Error('Failed to retrieve stack state after creation');
282    } catch (e) {
283      const error = e as any;
284      return { success: false, message: error.message, error };
285    }
286  },
287};

Update Stack

Finally, we implement update() function that takes care updating custom stacks.

src/my-custom-stack.ts
1import {
2  CustomStackState,
3  CustomStackHandler,
4  ParseConfigProps,
5  ParseConfigResult,
6  GetCurrentStateProps,
7  GetCurrentStateResult,
8  GetChangesProps,
9  GetChangesResult,
10  DeleteCustomStackProps,
11  DeleteCustomStackResult,
12  CreateCustomStackProps,
13  CreateCustomStackResult,
14  UpdateCustomStackProps,
15  UpdateCustomStackResult,
16} from 'takomo';
17import {
18  CloudFormationClient,
19  DescribeStacksCommand,
20  Stack,
21  DeleteStackCommand,
22  waitUntilStackDeleteComplete,
23  CreateStackCommand,
24  waitUntilStackCreateComplete,
25  UpdateStackCommand,
26  waitUntilStackUpdateComplete,
27} from '@aws-sdk/client-cloudformation';
28
29type MyCustomStackState = CustomStackState & {
30  // Here we can add more state information later
31};
32
33type MyCustomStackConfig = {
34  stackTemplate: string;
35};
36
37// Utility function to convert an array to record
38const arrayToRecord = <T extends object, K extends string | number | symbol, V>(
39  array: ReadonlyArray<T>,
40  keyExtractor: (item: T) => K,
41  valueExtractor: (item: T) => V,
42): Record<K, V> =>
43  array.reduce(
44    (collected, item) => ({
45      ...collected,
46      [keyExtractor(item)]: valueExtractor(item),
47    }),
48    {} as Record<K, V>,
49  );
50
51// Utility function to convert a CloudFormation stack to a CustomStackState
52const convertToCustomStackState = (stack?: Stack): CustomStackState => {
53  if (!stack) {
54    return { status: 'PENDING' };
55  }
56
57  const outputs = arrayToRecord(
58    stack.Outputs ?? [],
59    (output) => output.OutputKey!,
60    (output) => output.OutputValue!,
61  );
62
63  const tags = arrayToRecord(
64    stack.Tags ?? [],
65    (tag) => tag.Key!,
66    (tag) => tag.Value!,
67  );
68
69  const parameters = arrayToRecord(
70    stack.Parameters ?? [],
71    (param) => param.ParameterKey!,
72    (param) => param.ParameterValue!,
73  );
74
75  return { status: 'CREATE_COMPLETE', outputs, tags, parameters };
76};
77
78// Utility function to describe a CloudFormation stack
79const describeStack = async (
80  client: CloudFormationClient,
81  stackName: string,
82): Promise<CustomStackState | undefined> => {
83  try {
84    const { Stacks = [] } = await client.send(
85      new DescribeStacksCommand({ StackName: stackName }),
86    );
87
88    if (Stacks.length === 0) {
89      return undefined;
90    }
91
92    return convertToCustomStackState(Stacks[0]);
93  } catch (e) {
94    const error = e as any;
95    if (error.Code === 'ValidationError') {
96      if (error.message === `Stack with id ${stackName} does not exist`) {
97        return undefined;
98      }
99    }
100
101    throw e;
102  }
103};
104
105export const myCustomStackHandler: CustomStackHandler<
106  MyCustomStackConfig,
107  MyCustomStackState
108> = {
109  type: 'my-custom-stack',
110  parseConfig: async ({
111    rawConfig,
112  }: ParseConfigProps): Promise<ParseConfigResult<MyCustomStackConfig>> => {
113    const configObj = rawConfig as Record<string, unknown>;
114
115    // Validate that stackTemplate property is defined
116    if (configObj.stackTemplate === undefined) {
117      return {
118        success: false,
119        message: 'stackTemplate is required in custom stack config',
120      };
121    }
122
123    // Validate that stackTemplate is of type string
124    if (typeof configObj.stackTemplate !== 'string') {
125      return {
126        success: false,
127        message: 'stackTemplate is must be string in custom stack config',
128      };
129    }
130
131    return {
132      success: true,
133      parsedConfig: configObj as MyCustomStackConfig,
134    };
135  },
136  getCurrentState: async ({
137    stack,
138  }: GetCurrentStateProps<TestingCustomStackHandlerConfig>): Promise<
139    GetCurrentStateResult<CustomStackState>
140  > => {
141    const credentials = await stack.getCredentials();
142
143    const client = new CloudFormationClient({
144      credentials,
145      region: stack.region,
146    });
147
148    try {
149      const currentState = await describeStack(client, stack.name);
150
151      if (currentState) {
152        return {
153          currentState,
154          success: true,
155        };
156      }
157      return { currentState: { status: 'PENDING' }, success: true };
158    } catch (e) {
159      const error = e as any;
160      if (error.Code === 'ValidationError') {
161        if (error.message === `Stack with id ${stack.name} does not exist`) {
162          return { currentState: { status: 'PENDING' }, success: true };
163        }
164      }
165
166      return { success: false, error, message: error.message };
167    }
168  },
169  getChanges: async ({
170    stack,
171  }: GetChangesProps<
172    MyCustomStackConfig,
173    MyCustomStackState
174  >): Promise<GetChangesResult> => {
175    const credentials = await stack.getCredentials();
176
177    const client = new CloudFormationClient({
178      credentials,
179      region: stack.region,
180    });
181
182    try {
183      const state = await describeStack(client, stack.name);
184
185      if (state) {
186        return {
187          changes: [{ description: 'Stack exists and will be updated' }],
188          success: true,
189        };
190      }
191
192      return {
193        changes: [{ description: 'Stack does not exist and will be created' }],
194        success: true,
195      };
196    } catch (e) {
197      const error = e as any;
198      return { success: false, error, message: error.message };
199    }
200  },
201  delete: async ({
202    stack,
203    logger,
204  }: DeleteCustomStackProps<
205    TestingCustomStackHandlerConfig,
206    CustomStackState
207  >): Promise<DeleteCustomStackResult> => {
208    logger.info(`About to delete custom stack '${stack.name}'`);
209    const credentials = await stack.getCredentials();
210
211    const client = new CloudFormationClient({
212      credentials,
213      region: stack.region,
214    });
215
216    try {
217      logger.info(`Initiating stack deletion`);
218      await client.send(new DeleteStackCommand({ StackName: stack.name }));
219
220      logger.info(`Waiting for stack deletion to complete`);
221      await waitUntilStackDeleteComplete(
222        { client, maxWaitTime: 2 * 60 * 1000 },
223        { StackName: stack.name },
224      );
225
226      logger.info(`Stack deletion complete`);
227
228      return {
229        success: true,
230      };
231    } catch (e) {
232      const error = e as any;
233      return { success: false, message: error.message, error };
234    }
235  },
236  create: async ({
237    stack,
238    config,
239    tags,
240    parameters,
241    logger,
242  }: CreateCustomStackProps<TestingCustomStackHandlerConfig>): Promise<
243    CreateCustomStackResult<CustomStackState>
244  > => {
245    logger.info(`About to create custom stack '${stack.name}'`);
246
247    const credentials = await stack.getCredentials();
248
249    const client = new CloudFormationClient({
250      credentials,
251      region: stack.region,
252    });
253
254    try {
255      logger.info(`Initiating stack creation`);
256      await client.send(
257        new CreateStackCommand({
258          StackName: stack.name,
259          TemplateBody: config.stackTemplate,
260          Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
261          Parameters: Object.entries(parameters).map(
262            ([ParameterKey, ParameterValue]) => ({
263              ParameterKey,
264              ParameterValue,
265            }),
266          ),
267        }),
268      );
269      logger.info(`Waiting for stack creation to complete`);
270      await waitUntilStackCreateComplete(
271        { client, maxWaitTime: 3 * 60 * 1000 },
272        { StackName: stack.name },
273      );
274
275      logger.info(`Stack creation complete`);
276      const createdState = await describeStack(client, stack.name);
277
278      if (createdState) {
279        return {
280          createdState,
281          success: true,
282        };
283      }
284
285      throw new Error('Failed to retrieve stack state after creation');
286    } catch (e) {
287      const error = e as any;
288      return { success: false, message: error.message, error };
289    }
290  },
291  update: async ({
292    stack,
293    config,
294    tags,
295    parameters,
296    logger,
297  }: UpdateCustomStackProps<
298    TestingCustomStackHandlerConfig,
299    CustomStackState
300  >): Promise<UpdateCustomStackResult<CustomStackState>> => {
301    logger.info(`About to update custom stack '${stack.name}'`);
302
303    const credentials = await stack.getCredentials();
304
305    const client = new CloudFormationClient({
306      credentials,
307      region: stack.region,
308    });
309
310    try {
311      logger.info(`Initiating stack update`);
312      await client.send(
313        new UpdateStackCommand({
314          StackName: stack.name,
315          TemplateBody: config.stackTemplate,
316          Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
317          Parameters: Object.entries(parameters).map(
318            ([ParameterKey, ParameterValue]) => ({
319              ParameterKey,
320              ParameterValue,
321            }),
322          ),
323        }),
324      );
325
326      logger.info(`Waiting for stack update to complete`);
327      await waitUntilStackUpdateComplete(
328        { client, maxWaitTime: 3 * 60 * 1000 },
329        { StackName: stack.name },
330      );
331
332      logger.info(`Stack update complete`);
333
334      const updatedState = await describeStack(client, stack.name);
335
336      if (updatedState) {
337        return {
338          updatedState,
339          success: true,
340        };
341      }
342
343      throw new Error('Failed to retrieve stack state after update');
344    } catch (e) {
345      const error = e as any;
346      return { success: false, message: error.message, error };
347    }
348  },
349};

Register Custom Stack Handler

To actually use our custom stack handler, we need to register it in takomo.ts file located in your project's root directory.

1import { TakomoConfig, TakomoConfigProvider } from 'takomo';
2import { myCustomStackHandler } from './src/my-custom-stack.js';
3
4const provider: TakomoConfigProvider = async (): Promise<TakomoConfig> => {
5  return {
6    customStackHandlers: [myCustomStackHandler],
7  };
8};
9
10export default provider;

Using Custom Stack Handler in Stack Configuration

Now that we have implemented our custom stack handler and registered it in Takomo project configuration, we can start using it in stack configuration files. Add a stack configuration file named my-stack.yml to the stacks directory. Set customType to my-custom-stack and provide template for your stack with customConfig property.

1regions: eu-north-1
2customType: my-custom-stack
3customConfig:
4  stackTemplate: |
5    Resources:
6      LogGroup:
7        Type: AWS::Logs::LogGroup
8        Properties: {}
9tags:
10  Project: My example stack

Conclusion

Now you should be able to implement your own custom stack handlers. Take a look at API documentation for more details.