import * as cloudformation from "@distilled.cloud/aws/cloudformation"; import * as Data from "effect/Data"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import { toWireMinutes } from "../../Util/Duration.ts"; /** * Bounded wait for a stack to leave an in-progress status. Explicitly-typed * pipeable helper — an inline `Effect.retry` in a provider lifecycle op leaks * `Retry.Return`'s conditional into declaration emit and widens the provider * layer to `unknown` R for every `AWS.providers()` consumer. */ const retryUntilStackSettled = ( self: Effect.Effect, ): Effect.Effect => Effect.retry(self, { while: (e) => e._tag === "StackNotSettled", schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(120)]), }); import * as Stream from "effect/Stream"; import { Unowned } from "../../AdoptPolicy.ts"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { createInternalTags, hasAlchemyTags } from "../../Tags.ts"; import type { Providers } from "../Providers.ts"; /** * Behaviour when stack creation fails. * - `ROLLBACK` (default) — roll the stack back and delete created resources. * - `DO_NOTHING` — leave created resources in place for inspection. * - `DELETE` — delete the stack and all created resources. */ export type OnFailure = "DO_NOTHING" | "ROLLBACK" | "DELETE"; export interface StackProps { /** * Name of the CloudFormation stack. Must be 1-128 characters, start with a * letter, and contain only letters, digits, and hyphens. If omitted, a * deterministic physical name is generated. Changing the name replaces the * stack. */ stackName?: string; /** * The CloudFormation template as an inline JSON or YAML string. Mutually * exclusive with `templateURL`. Maximum 51,200 bytes — use `templateURL` * for larger templates. */ templateBody?: string; /** * Location of a template stored in Amazon S3 (an `https://` URL to an S3 * object). Mutually exclusive with `templateBody`. */ templateURL?: string; /** * Input parameter values for the template, keyed by parameter name. */ parameters?: Record; /** * IAM capabilities the stack is allowed to acknowledge. Required when the * template creates IAM resources (`CAPABILITY_IAM` / * `CAPABILITY_NAMED_IAM`) or uses macros (`CAPABILITY_AUTO_EXPAND`). */ capabilities?: Array< "CAPABILITY_IAM" | "CAPABILITY_NAMED_IAM" | "CAPABILITY_AUTO_EXPAND" >; /** * ARN of an IAM role that CloudFormation assumes to create/update/delete * the stack's resources. Defaults to the credentials of the deploying * principal. */ roleArn?: string; /** * SNS topic ARNs that receive stack event notifications. */ notificationARNs?: string[]; /** * Whether to disable rollback of the stack if creation fails. * @default false */ disableRollback?: boolean; /** * Behaviour when stack creation fails. Applied only on create. * @default "ROLLBACK" */ onFailure?: OnFailure; /** * Amount of time that can pass before the stack status becomes * `CREATE_FAILED` (e.g. `"30 minutes"` or `Duration.minutes(30)`; a bare * number is milliseconds). Converted to whole minutes on the wire. */ timeout?: Duration.Input; /** * User-defined tags propagated to every resource in the stack. */ tags?: Record; } export interface Stack extends Resource< "AWS.CloudFormation.Stack", StackProps, { /** * Name of the stack. */ stackName: string; /** * The unique stack ID (ARN). */ stackId: string; /** * Current status of the stack (e.g. `CREATE_COMPLETE`, `UPDATE_COMPLETE`). */ stackStatus: string; /** Template outputs keyed by output name. */ outputs: Record; }, never, Providers > {} /** * An AWS CloudFormation stack — deploy an existing CloudFormation template * from Alchemy as an interop/escape hatch. * * Create and update are asynchronous: the provider submits the template and * then polls (bounded) until the stack reaches a terminal state, surfacing a * `CREATE_FAILED` / `ROLLBACK_COMPLETE` / `UPDATE_ROLLBACK_COMPLETE` status as * a typed error rather than hanging. An update whose template and parameters * are unchanged is a no-op (`No updates are to be performed`). Deletion waits * for `DELETE_COMPLETE`. * ### Deploying a Template * **Example:** Inline Template (SNS Topic) * ```typescript * const stack = yield* CloudFormation.Stack("Notifications", { * templateBody: JSON.stringify({ * Resources: { * Topic: { Type: "AWS::SNS::Topic", Properties: { DisplayName: "alerts" } }, * }, * Outputs: { TopicArn: { Value: { Ref: "Topic" } } }, * }), * }); * // stack.outputs.TopicArn -> "arn:aws:sns:us-west-2:...:Notifications-Topic-..." * ``` * * **Example:** Template with Parameters * ```typescript * const stack = yield* CloudFormation.Stack("Config", { * templateBody: JSON.stringify({ * Parameters: { Value: { Type: "String" } }, * Resources: { * Param: { * Type: "AWS::SSM::Parameter", * Properties: { Type: "String", Value: { Ref: "Value" } }, * }, * }, * }), * parameters: { Value: "hello" }, * }); * ``` * * ### IAM Templates * **Example:** Acknowledging Capabilities * ```typescript * const stack = yield* CloudFormation.Stack("Roles", { * templateBody: iamTemplateJson, * capabilities: ["CAPABILITY_NAMED_IAM"], * }); * ``` * * @resource */ export const Stack = Resource("AWS.CloudFormation.Stack"); class StackNotSettled extends Data.TaggedError("StackNotSettled")<{ readonly stackId: string; readonly status: string; }> {} class StackOperationFailed extends Data.TaggedError("StackOperationFailed")<{ readonly stackName: string; readonly status: string; readonly reason: string | undefined; }> {} /** A stack in one of these statuses is mid-operation. */ const isInProgress = (status: string | undefined): boolean => status?.endsWith("_IN_PROGRESS") ?? false; /** * A ROLLBACK_COMPLETE / ROLLBACK_FAILED create leaves the stack in a * non-updatable "created but failed" state — it can only be deleted. */ const isFailedCreateRemnant = (status: string | undefined): boolean => status === "ROLLBACK_COMPLETE" || status === "ROLLBACK_FAILED"; const isFailure = (status: string | undefined): boolean => (status?.endsWith("_FAILED") ?? false) || status === "ROLLBACK_COMPLETE" || status === "UPDATE_ROLLBACK_COMPLETE"; const toParameters = ( parameters: Record | undefined, ): cloudformation.Parameter[] | undefined => parameters === undefined ? undefined : Object.entries(parameters).map(([ParameterKey, ParameterValue]) => ({ ParameterKey, ParameterValue, })); const toWireTags = (tags: Record): cloudformation.Tag[] => Object.entries(tags).map(([Key, Value]) => ({ Key, Value })); const toTagRecord = ( tags: cloudformation.Tag[] | undefined, ): Record => Object.fromEntries( (tags ?? []) .filter( (tag): tag is { Key: string; Value: string } => typeof tag.Key === "string" && typeof tag.Value === "string", ) .map((tag) => [tag.Key, tag.Value]), ); const toOutputs = ( outputs: cloudformation.Output[] | undefined, ): Record => Object.fromEntries( (outputs ?? []).flatMap((o) => o.OutputKey !== undefined && o.OutputValue !== undefined ? [[o.OutputKey, o.OutputValue]] : [], ), ); export const StackProvider = () => Provider.effect( Stack, Effect.gen(function* () { const toName = (id: string, props: Partial) => props.stackName ? Effect.succeed(props.stackName) : createPhysicalName({ id, maxLength: 128 }); /** * Describe a stack by name or id. A missing stack (typed * `StackNotFound`) or a fully-deleted stack reads as absent. */ const describe = Effect.fn(function* (nameOrId: string) { const response = yield* cloudformation .describeStacks({ StackName: nameOrId }) .pipe( Effect.map((r) => r.Stacks ?? []), Effect.catchTag("StackNotFound", () => Effect.succeed([])), ); const stack = response[0]; return stack === undefined || stack.StackStatus === "DELETE_COMPLETE" ? undefined : stack; }); // Create/update run asynchronously; CloudFormation reports *_IN_PROGRESS // while converging. A small stack settles in ~1-2 minutes; budget // ~10 min (120 * 5s). const waitForSettled = Effect.fn(function* ( stackId: string, stackName: string, ) { const stack = yield* describe(stackId).pipe( Effect.flatMap((s) => s !== undefined && isInProgress(s.StackStatus) ? Effect.fail( new StackNotSettled({ stackId, status: s.StackStatus ?? "UNKNOWN", }), ) : Effect.succeed(s), ), retryUntilStackSettled, ); if (stack !== undefined && isFailure(stack.StackStatus)) { return yield* Effect.fail( new StackOperationFailed({ stackName, status: stack.StackStatus ?? "UNKNOWN", reason: stack.StackStatusReason, }), ); } return stack; }); // Deletion is asynchronous too — wait until the stack reports // DELETE_COMPLETE (or vanishes) so dependencies can be torn down after. const waitUntilGone = Effect.fn(function* ( stackId: string, stackName: string, ) { yield* retryUntilStackSettled( describe(stackId).pipe( Effect.flatMap( ( s, ): Effect.Effect< void, StackNotSettled | StackOperationFailed > => { if (s === undefined) return Effect.void; if (s.StackStatus === "DELETE_FAILED") { return Effect.fail( new StackOperationFailed({ stackName, status: "DELETE_FAILED", reason: s.StackStatusReason, }), ); } return Effect.fail( new StackNotSettled({ stackId, status: s.StackStatus ?? "UNKNOWN", }), ); }, ), ), ); }); const toAttrs = (stack: cloudformation.Stack) => ({ stackName: stack.StackName!, stackId: stack.StackId!, stackStatus: stack.StackStatus!, outputs: toOutputs(stack.Outputs), }); return { stables: ["stackName", "stackId"], diff: Effect.fn(function* ({ id, olds, news }) { if (!isResolved(news)) return undefined; if ( (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {})) ) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ id, olds, output }) { const stack = yield* describe( output?.stackId ?? (yield* toName(id, olds ?? {})), ); if (stack === undefined) return undefined; const attrs = toAttrs(stack); const tags = toTagRecord(stack.Tags); return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs); }), reconcile: Effect.fn(function* ({ id, news, output, session }) { const name = output?.stackName ?? (yield* toName(id, news)); const internalTags = yield* createInternalTags(id); const desiredTags = { ...internalTags, ...news.tags }; // 1. Observe — cloud state is authoritative; output is only an id // cache. let observed = yield* describe(output?.stackId ?? name); // A stack left in ROLLBACK_COMPLETE/ROLLBACK_FAILED by a failed // create can only be deleted; clear it before recreating. if ( observed !== undefined && isFailedCreateRemnant(observed.StackStatus) ) { yield* cloudformation.deleteStack({ StackName: observed.StackId! }); yield* waitUntilGone(observed.StackId!, name); observed = undefined; } // 2. Ensure — create if missing, then wait for CREATE_COMPLETE. if (observed === undefined) { const created = yield* cloudformation.createStack({ StackName: name, TemplateBody: news.templateBody, TemplateURL: news.templateURL, Parameters: toParameters(news.parameters), Capabilities: news.capabilities, RoleARN: news.roleArn, NotificationARNs: news.notificationARNs, DisableRollback: news.disableRollback, OnFailure: news.onFailure, TimeoutInMinutes: toWireMinutes(news.timeout), Tags: toWireTags(desiredTags), }); const settled = yield* waitForSettled(created.StackId!, name); if (settled === undefined) { return yield* Effect.fail( new StackOperationFailed({ stackName: name, status: "DELETE_COMPLETE", reason: "Stack disappeared immediately after creation", }), ); } yield* session.note(name); return toAttrs(settled); } // 3. Sync — submit the desired template/params/tags. An unchanged // template is a no-op (typed NoUpdateToPerform); skip the wait. const didUpdate = yield* cloudformation .updateStack({ StackName: observed.StackId!, TemplateBody: news.templateBody, TemplateURL: news.templateURL, Parameters: toParameters(news.parameters), Capabilities: news.capabilities, RoleARN: news.roleArn, NotificationARNs: news.notificationARNs, DisableRollback: news.disableRollback, Tags: toWireTags(desiredTags), }) .pipe( Effect.as(true), Effect.catchTag("NoUpdateToPerform", () => Effect.succeed(false)), ); const final = didUpdate ? yield* waitForSettled(observed.StackId!, name) : observed; if (final === undefined) { return yield* Effect.fail( new StackOperationFailed({ stackName: name, status: "DELETE_COMPLETE", reason: "Stack disappeared while updating", }), ); } // 4. Return fresh attributes. yield* session.note(name); return toAttrs(final); }), delete: Effect.fn(function* ({ output }) { yield* cloudformation.deleteStack({ StackName: output.stackId }); yield* waitUntilGone(output.stackId, output.stackName); }), list: () => cloudformation.describeStacks.pages({}).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk) .flatMap((page) => page.Stacks ?? []) .flatMap((s) => s.StackName !== undefined && s.StackId !== undefined && s.StackStatus !== undefined && s.StackStatus !== "DELETE_COMPLETE" ? [ { stackName: s.StackName, stackId: s.StackId, stackStatus: s.StackStatus, outputs: toOutputs(s.Outputs), }, ] : [], ), ), Effect.catchTag("StackNotFound", () => Effect.succeed([])), ), }; }), );