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 😀.
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.
1import { CustomStackState } from 'takomo';
2
3type MyCustomStackState = CustomStackState & {
4 // Here we can add more state information later
5};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.
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};Each custom stack handler needs to have a unique type which you can reference from stack configuration files.
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};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.
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};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.
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};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.
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};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.
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};Next, we implement create() function that takes care creating custom stacks.
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};Finally, we implement update() function that takes care updating custom stacks.
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};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;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 stackNow you should be able to implement your own custom stack handlers. Take a look at API documentation for more details.