1import * as cloudformation from "@distilled.cloud/aws/cloudformation";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { toWireMinutes } from "../../Util/Duration.ts";
7
8/**
9 * Bounded wait for a stack to leave an in-progress status. Explicitly-typed
10 * pipeable helper — an inline `Effect.retry` in a provider lifecycle op leaks
11 * `Retry.Return`'s conditional into declaration emit and widens the provider
12 * layer to `unknown` R for every `AWS.providers()` consumer.
13 */
14const retryUntilStackSettled = <A, E extends { readonly _tag: string }, R>(
15 self: Effect.Effect<A, E, R>,
16): Effect.Effect<A, E, R> =>
17 Effect.retry(self, {
18 while: (e) => e._tag === "StackNotSettled",
19 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(120)]),
20 });
21import * as Stream from "effect/Stream";
22import { Unowned } from "../../AdoptPolicy.ts";
23import { isResolved } from "../../Diff.ts";
24import { createPhysicalName } from "../../PhysicalName.ts";
25import * as Provider from "../../Provider.ts";
26import { Resource } from "../../Resource.ts";
27import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
28import type { Providers } from "../Providers.ts";
29
30/**
31 * Behaviour when stack creation fails.
32 * - `ROLLBACK` (default) — roll the stack back and delete created resources.
33 * - `DO_NOTHING` — leave created resources in place for inspection.
34 * - `DELETE` — delete the stack and all created resources.
35 */
36export type OnFailure = "DO_NOTHING" | "ROLLBACK" | "DELETE";
37
38export interface StackProps {
39 /**
40 * Name of the CloudFormation stack. Must be 1-128 characters, start with a
41 * letter, and contain only letters, digits, and hyphens. If omitted, a
42 * deterministic physical name is generated. Changing the name replaces the
43 * stack.
44 */
45 stackName?: string;
46 /**
47 * The CloudFormation template as an inline JSON or YAML string. Mutually
48 * exclusive with `templateURL`. Maximum 51,200 bytes — use `templateURL`
49 * for larger templates.
50 */
51 templateBody?: string;
52 /**
53 * Location of a template stored in Amazon S3 (an `https://` URL to an S3
54 * object). Mutually exclusive with `templateBody`.
55 */
56 templateURL?: string;
57 /**
58 * Input parameter values for the template, keyed by parameter name.
59 */
60 parameters?: Record<string, string>;
61 /**
62 * IAM capabilities the stack is allowed to acknowledge. Required when the
63 * template creates IAM resources (`CAPABILITY_IAM` /
64 * `CAPABILITY_NAMED_IAM`) or uses macros (`CAPABILITY_AUTO_EXPAND`).
65 */
66 capabilities?: Array<
67 "CAPABILITY_IAM" | "CAPABILITY_NAMED_IAM" | "CAPABILITY_AUTO_EXPAND"
68 >;
69 /**
70 * ARN of an IAM role that CloudFormation assumes to create/update/delete
71 * the stack's resources. Defaults to the credentials of the deploying
72 * principal.
73 */
74 roleArn?: string;
75 /**
76 * SNS topic ARNs that receive stack event notifications.
77 */
78 notificationARNs?: string[];
79 /**
80 * Whether to disable rollback of the stack if creation fails.
81 * @default false
82 */
83 disableRollback?: boolean;
84 /**
85 * Behaviour when stack creation fails. Applied only on create.
86 * @default "ROLLBACK"
87 */
88 onFailure?: OnFailure;
89 /**
90 * Amount of time that can pass before the stack status becomes
91 * `CREATE_FAILED` (e.g. `"30 minutes"` or `Duration.minutes(30)`; a bare
92 * number is milliseconds). Converted to whole minutes on the wire.
93 */
94 timeout?: Duration.Input;
95 /**
96 * User-defined tags propagated to every resource in the stack.
97 */
98 tags?: Record<string, string>;
99}
100
101export interface Stack extends Resource<
102 "AWS.CloudFormation.Stack",
103 StackProps,
104 {
105 /**
106 * Name of the stack.
107 */
108 stackName: string;
109 /**
110 * The unique stack ID (ARN).
111 */
112 stackId: string;
113 /**
114 * Current status of the stack (e.g. `CREATE_COMPLETE`, `UPDATE_COMPLETE`).
115 */
116 stackStatus: string;
117 /** Template outputs keyed by output name. */
118 outputs: Record<string, string>;
119 },
120 never,
121 Providers
122> {}
123
124/**
125 * An AWS CloudFormation stack — deploy an existing CloudFormation template
126 * from Alchemy as an interop/escape hatch.
127 *
128 * Create and update are asynchronous: the provider submits the template and
129 * then polls (bounded) until the stack reaches a terminal state, surfacing a
130 * `CREATE_FAILED` / `ROLLBACK_COMPLETE` / `UPDATE_ROLLBACK_COMPLETE` status as
131 * a typed error rather than hanging. An update whose template and parameters
132 * are unchanged is a no-op (`No updates are to be performed`). Deletion waits
133 * for `DELETE_COMPLETE`.
134 * ### Deploying a Template
135 * **Example:** Inline Template (SNS Topic)
136 * ```typescript
137 * const stack = yield* CloudFormation.Stack("Notifications", {
138 * templateBody: JSON.stringify({
139 * Resources: {
140 * Topic: { Type: "AWS::SNS::Topic", Properties: { DisplayName: "alerts" } },
141 * },
142 * Outputs: { TopicArn: { Value: { Ref: "Topic" } } },
143 * }),
144 * });
145 * // stack.outputs.TopicArn -> "arn:aws:sns:us-west-2:...:Notifications-Topic-..."
146 * ```
147 *
148 * **Example:** Template with Parameters
149 * ```typescript
150 * const stack = yield* CloudFormation.Stack("Config", {
151 * templateBody: JSON.stringify({
152 * Parameters: { Value: { Type: "String" } },
153 * Resources: {
154 * Param: {
155 * Type: "AWS::SSM::Parameter",
156 * Properties: { Type: "String", Value: { Ref: "Value" } },
157 * },
158 * },
159 * }),
160 * parameters: { Value: "hello" },
161 * });
162 * ```
163 *
164 * ### IAM Templates
165 * **Example:** Acknowledging Capabilities
166 * ```typescript
167 * const stack = yield* CloudFormation.Stack("Roles", {
168 * templateBody: iamTemplateJson,
169 * capabilities: ["CAPABILITY_NAMED_IAM"],
170 * });
171 * ```
172 *
173 * @resource
174 */
175export const Stack = Resource<Stack>("AWS.CloudFormation.Stack");
176
177class StackNotSettled extends Data.TaggedError("StackNotSettled")<{
178 readonly stackId: string;
179 readonly status: string;
180}> {}
181
182class StackOperationFailed extends Data.TaggedError("StackOperationFailed")<{
183 readonly stackName: string;
184 readonly status: string;
185 readonly reason: string | undefined;
186}> {}
187
188/** A stack in one of these statuses is mid-operation. */
189const isInProgress = (status: string | undefined): boolean =>
190 status?.endsWith("_IN_PROGRESS") ?? false;
191
192/**
193 * A ROLLBACK_COMPLETE / ROLLBACK_FAILED create leaves the stack in a
194 * non-updatable "created but failed" state — it can only be deleted.
195 */
196const isFailedCreateRemnant = (status: string | undefined): boolean =>
197 status === "ROLLBACK_COMPLETE" || status === "ROLLBACK_FAILED";
198
199const isFailure = (status: string | undefined): boolean =>
200 (status?.endsWith("_FAILED") ?? false) ||
201 status === "ROLLBACK_COMPLETE" ||
202 status === "UPDATE_ROLLBACK_COMPLETE";
203
204const toParameters = (
205 parameters: Record<string, string> | undefined,
206): cloudformation.Parameter[] | undefined =>
207 parameters === undefined
208 ? undefined
209 : Object.entries(parameters).map(([ParameterKey, ParameterValue]) => ({
210 ParameterKey,
211 ParameterValue,
212 }));
213
214const toWireTags = (tags: Record<string, string>): cloudformation.Tag[] =>
215 Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
216
217const toTagRecord = (
218 tags: cloudformation.Tag[] | undefined,
219): Record<string, string> =>
220 Object.fromEntries(
221 (tags ?? [])
222 .filter(
223 (tag): tag is { Key: string; Value: string } =>
224 typeof tag.Key === "string" && typeof tag.Value === "string",
225 )
226 .map((tag) => [tag.Key, tag.Value]),
227 );
228
229const toOutputs = (
230 outputs: cloudformation.Output[] | undefined,
231): Record<string, string> =>
232 Object.fromEntries(
233 (outputs ?? []).flatMap((o) =>
234 o.OutputKey !== undefined && o.OutputValue !== undefined
235 ? [[o.OutputKey, o.OutputValue]]
236 : [],
237 ),
238 );
239
240export const StackProvider = () =>
241 Provider.effect(
242 Stack,
243 Effect.gen(function* () {
244 const toName = (id: string, props: Partial<StackProps>) =>
245 props.stackName
246 ? Effect.succeed(props.stackName)
247 : createPhysicalName({ id, maxLength: 128 });
248
249 /**
250 * Describe a stack by name or id. A missing stack (typed
251 * `StackNotFound`) or a fully-deleted stack reads as absent.
252 */
253 const describe = Effect.fn(function* (nameOrId: string) {
254 const response = yield* cloudformation
255 .describeStacks({ StackName: nameOrId })
256 .pipe(
257 Effect.map((r) => r.Stacks ?? []),
258 Effect.catchTag("StackNotFound", () => Effect.succeed([])),
259 );
260 const stack = response[0];
261 return stack === undefined || stack.StackStatus === "DELETE_COMPLETE"
262 ? undefined
263 : stack;
264 });
265
266 // Create/update run asynchronously; CloudFormation reports *_IN_PROGRESS
267 // while converging. A small stack settles in ~1-2 minutes; budget
268 // ~10 min (120 * 5s).
269 const waitForSettled = Effect.fn(function* (
270 stackId: string,
271 stackName: string,
272 ) {
273 const stack = yield* describe(stackId).pipe(
274 Effect.flatMap((s) =>
275 s !== undefined && isInProgress(s.StackStatus)
276 ? Effect.fail(
277 new StackNotSettled({
278 stackId,
279 status: s.StackStatus ?? "UNKNOWN",
280 }),
281 )
282 : Effect.succeed(s),
283 ),
284 retryUntilStackSettled,
285 );
286 if (stack !== undefined && isFailure(stack.StackStatus)) {
287 return yield* Effect.fail(
288 new StackOperationFailed({
289 stackName,
290 status: stack.StackStatus ?? "UNKNOWN",
291 reason: stack.StackStatusReason,
292 }),
293 );
294 }
295 return stack;
296 });
297
298 // Deletion is asynchronous too — wait until the stack reports
299 // DELETE_COMPLETE (or vanishes) so dependencies can be torn down after.
300 const waitUntilGone = Effect.fn(function* (
301 stackId: string,
302 stackName: string,
303 ) {
304 yield* retryUntilStackSettled(
305 describe(stackId).pipe(
306 Effect.flatMap(
307 (
308 s,
309 ): Effect.Effect<
310 void,
311 StackNotSettled | StackOperationFailed
312 > => {
313 if (s === undefined) return Effect.void;
314 if (s.StackStatus === "DELETE_FAILED") {
315 return Effect.fail(
316 new StackOperationFailed({
317 stackName,
318 status: "DELETE_FAILED",
319 reason: s.StackStatusReason,
320 }),
321 );
322 }
323 return Effect.fail(
324 new StackNotSettled({
325 stackId,
326 status: s.StackStatus ?? "UNKNOWN",
327 }),
328 );
329 },
330 ),
331 ),
332 );
333 });
334
335 const toAttrs = (stack: cloudformation.Stack) => ({
336 stackName: stack.StackName!,
337 stackId: stack.StackId!,
338 stackStatus: stack.StackStatus!,
339 outputs: toOutputs(stack.Outputs),
340 });
341
342 return {
343 stables: ["stackName", "stackId"],
344
345 diff: Effect.fn(function* ({ id, olds, news }) {
346 if (!isResolved(news)) return undefined;
347 if (
348 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
349 ) {
350 return { action: "replace" } as const;
351 }
352 }),
353
354 read: Effect.fn(function* ({ id, olds, output }) {
355 const stack = yield* describe(
356 output?.stackId ?? (yield* toName(id, olds ?? {})),
357 );
358 if (stack === undefined) return undefined;
359 const attrs = toAttrs(stack);
360 const tags = toTagRecord(stack.Tags);
361 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
362 }),
363
364 reconcile: Effect.fn(function* ({ id, news, output, session }) {
365 const name = output?.stackName ?? (yield* toName(id, news));
366 const internalTags = yield* createInternalTags(id);
367 const desiredTags = { ...internalTags, ...news.tags };
368
369 // 1. Observe — cloud state is authoritative; output is only an id
370 // cache.
371 let observed = yield* describe(output?.stackId ?? name);
372
373 // A stack left in ROLLBACK_COMPLETE/ROLLBACK_FAILED by a failed
374 // create can only be deleted; clear it before recreating.
375 if (
376 observed !== undefined &&
377 isFailedCreateRemnant(observed.StackStatus)
378 ) {
379 yield* cloudformation.deleteStack({ StackName: observed.StackId! });
380 yield* waitUntilGone(observed.StackId!, name);
381 observed = undefined;
382 }
383
384 // 2. Ensure — create if missing, then wait for CREATE_COMPLETE.
385 if (observed === undefined) {
386 const created = yield* cloudformation.createStack({
387 StackName: name,
388 TemplateBody: news.templateBody,
389 TemplateURL: news.templateURL,
390 Parameters: toParameters(news.parameters),
391 Capabilities: news.capabilities,
392 RoleARN: news.roleArn,
393 NotificationARNs: news.notificationARNs,
394 DisableRollback: news.disableRollback,
395 OnFailure: news.onFailure,
396 TimeoutInMinutes: toWireMinutes(news.timeout),
397 Tags: toWireTags(desiredTags),
398 });
399 const settled = yield* waitForSettled(created.StackId!, name);
400 if (settled === undefined) {
401 return yield* Effect.fail(
402 new StackOperationFailed({
403 stackName: name,
404 status: "DELETE_COMPLETE",
405 reason: "Stack disappeared immediately after creation",
406 }),
407 );
408 }
409 yield* session.note(name);
410 return toAttrs(settled);
411 }
412
413 // 3. Sync — submit the desired template/params/tags. An unchanged
414 // template is a no-op (typed NoUpdateToPerform); skip the wait.
415 const didUpdate = yield* cloudformation
416 .updateStack({
417 StackName: observed.StackId!,
418 TemplateBody: news.templateBody,
419 TemplateURL: news.templateURL,
420 Parameters: toParameters(news.parameters),
421 Capabilities: news.capabilities,
422 RoleARN: news.roleArn,
423 NotificationARNs: news.notificationARNs,
424 DisableRollback: news.disableRollback,
425 Tags: toWireTags(desiredTags),
426 })
427 .pipe(
428 Effect.as(true),
429 Effect.catchTag("NoUpdateToPerform", () => Effect.succeed(false)),
430 );
431
432 const final = didUpdate
433 ? yield* waitForSettled(observed.StackId!, name)
434 : observed;
435 if (final === undefined) {
436 return yield* Effect.fail(
437 new StackOperationFailed({
438 stackName: name,
439 status: "DELETE_COMPLETE",
440 reason: "Stack disappeared while updating",
441 }),
442 );
443 }
444
445 // 4. Return fresh attributes.
446 yield* session.note(name);
447 return toAttrs(final);
448 }),
449
450 delete: Effect.fn(function* ({ output }) {
451 yield* cloudformation.deleteStack({ StackName: output.stackId });
452 yield* waitUntilGone(output.stackId, output.stackName);
453 }),
454
455 list: () =>
456 cloudformation.describeStacks.pages({}).pipe(
457 Stream.runCollect,
458 Effect.map((chunk) =>
459 Array.from(chunk)
460 .flatMap((page) => page.Stacks ?? [])
461 .flatMap((s) =>
462 s.StackName !== undefined &&
463 s.StackId !== undefined &&
464 s.StackStatus !== undefined &&
465 s.StackStatus !== "DELETE_COMPLETE"
466 ? [
467 {
468 stackName: s.StackName,
469 stackId: s.StackId,
470 stackStatus: s.StackStatus,
471 outputs: toOutputs(s.Outputs),
472 },
473 ]
474 : [],
475 ),
476 ),
477 Effect.catchTag("StackNotFound", () => Effect.succeed([])),
478 ),
479 };
480 }),
481 );