1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Stream from "effect/Stream";
4
5import * as Namespace from "../../Namespace.ts";
6import type { Application } from "../AppConfig/Application.ts";
7import {
8 DeploymentEventSource as AppConfigDeploymentEventSourceContract,
9 type DeploymentEventRecord,
10 type DeploymentEventSourceProps,
11 type DeploymentEventSourceService,
12 type DeploymentEventType,
13} from "../AppConfig/DeploymentEventSource.ts";
14import type { Environment } from "../AppConfig/Environment.ts";
15import { Extension } from "../AppConfig/Extension.ts";
16import { ExtensionAssociation } from "../AppConfig/ExtensionAssociation.ts";
17import { Role } from "../IAM/Role.ts";
18import * as Lambda from "./Function.ts";
19
20const ALL_DEPLOYMENT_EVENTS: DeploymentEventType[] = [
21 "ON_DEPLOYMENT_START",
22 "ON_DEPLOYMENT_STEP",
23 "ON_DEPLOYMENT_BAKING",
24 "ON_DEPLOYMENT_COMPLETE",
25 "ON_DEPLOYMENT_ROLLED_BACK",
26];
27
28/** Short, deterministic suffix distinguishing subscriptions by action-point set. */
29const eventSetSuffix = (events: DeploymentEventType[]): string =>
30 [...events]
31 .sort()
32 .map((event) =>
33 event
34 .replace("ON_DEPLOYMENT_", "")
35 .toLowerCase()
36 .split("_")
37 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
38 .join(""),
39 )
40 .join("");
41
42/**
43 * Narrow an arbitrary Lambda invocation payload to an AppConfig extension
44 * deployment notification.
45 */
46export const isAppConfigDeploymentEvent = (
47 event: any,
48): event is DeploymentEventRecord =>
49 typeof event?.InvocationId === "string" &&
50 typeof event?.Type === "string" &&
51 typeof event?.Application === "object";
52
53const isEnvironment = (
54 target: Application | Environment,
55): target is Environment => target.Type === "AWS.AppConfig.Environment";
56
57/**
58 * Lambda runtime implementation for `AWS.AppConfig.consumeDeploymentEvents(...)`.
59 *
60 * This layer does two things:
61 *
62 * 1. At deploy time it provisions an AppConfig {@link Extension} whose
63 * actions invoke the current Lambda function at the subscribed deployment
64 * action points, the IAM role AppConfig assumes to perform the
65 * invocation, and an {@link ExtensionAssociation} attaching the extension
66 * to the target application or environment.
67 * 2. At runtime it narrows incoming invocations to AppConfig deployment
68 * notifications for the bound target and forwards them into the supplied
69 * handler as a typed `DeploymentEventRecord` stream.
70 * ### Consuming Deployment Events
71 * **Example:** Record Completed Deployments
72 * ```typescript
73 * yield* AppConfig.consumeDeploymentEvents(
74 * env,
75 * { events: ["ON_DEPLOYMENT_COMPLETE"] },
76 * (events) =>
77 * events.pipe(
78 * Stream.runForEach((event) =>
79 * Effect.log(`deployment ${event.DeploymentNumber} completed`),
80 * ),
81 * ),
82 * );
83 * ```
84 *
85 * @binding
86 */
87export const AppConfigDeploymentEventSource = Layer.effect(
88 AppConfigDeploymentEventSourceContract,
89 // The impl resolves plan-time services (Role, Extension, Association)
90 // whereas DeploymentEventSourceService erases the requirement channel to
91 // `never`.
92 // @effect-diagnostics-next-line missingEffectContext:off
93 Effect.gen(function* () {
94 const host = yield* Lambda.Function;
95 const InvokeRole = yield* Role;
96 const DeploymentExtension = yield* Extension;
97 const DeploymentExtensionAssociation = yield* ExtensionAssociation;
98
99 return Effect.fn(function* <Req = never>(
100 target: Application | Environment,
101 props: DeploymentEventSourceProps,
102 process: (
103 events: Stream.Stream<DeploymentEventRecord>,
104 ) => Effect.Effect<void, never, Req>,
105 ) {
106 const events = props.events ?? ALL_DEPLOYMENT_EVENTS;
107 const suffix = eventSetSuffix(events);
108
109 // Resolving the target's id also registers it on the host environment;
110 // re-yield per invocation inside the listener below.
111 const TargetId = yield* isEnvironment(target)
112 ? target.environmentId
113 : target.applicationId;
114
115 // Deploy-time: provision the invoke role, the extension whose actions
116 // call this function at each subscribed action point, and the
117 // association binding the extension to the target. Skipped once running
118 // inside the deployed Function (the global guard), where the only work
119 // is registering the runtime handler below. Namespaced under the host
120 // so the sub-resources' logical identity is stable per host function.
121 if (!globalThis.__ALCHEMY_RUNTIME__) {
122 yield* Namespace.push(
123 host.LogicalId,
124 Effect.gen(function* () {
125 // NB: hyphenated logical ids only — the id lands in the
126 // `alchemy::id` tag value, and IAM/AppConfig tag values reject
127 // `(`, `)` and `,`.
128 const role = yield* InvokeRole(
129 `${target.LogicalId}-${suffix}-InvokeRole`,
130 {
131 assumeRolePolicyDocument: {
132 Version: "2012-10-17",
133 Statement: [
134 {
135 Effect: "Allow",
136 Principal: { Service: "appconfig.amazonaws.com" },
137 Action: ["sts:AssumeRole"],
138 },
139 ],
140 },
141 inlinePolicies: {
142 InvokeFunction: {
143 Version: "2012-10-17",
144 Statement: [
145 {
146 Effect: "Allow",
147 Action: ["lambda:InvokeFunction"],
148 Resource: [host.functionArn as any],
149 },
150 ],
151 },
152 },
153 },
154 );
155
156 const extension = yield* DeploymentExtension(
157 `${target.LogicalId}-${suffix}-DeploymentEvents`,
158 {
159 description: `Deployment notifications for ${target.LogicalId} -> ${host.LogicalId}`,
160 actions: Object.fromEntries(
161 events.map((event) => [
162 event,
163 [
164 {
165 name: event,
166 uri: host.functionArn as any,
167 roleArn: role.roleArn as any,
168 },
169 ],
170 ]),
171 ),
172 },
173 );
174
175 yield* DeploymentExtensionAssociation(
176 `${target.LogicalId}-${suffix}-DeploymentEventsAssociation`,
177 {
178 extensionIdentifier: extension.extensionId as any,
179 resourceIdentifier: (isEnvironment(target)
180 ? target.environmentArn
181 : target.applicationArn) as any,
182 },
183 );
184 }),
185 );
186 }
187
188 yield* host.listen(
189 Effect.gen(function* () {
190 const targetId = yield* TargetId;
191 const subscribed = new Set(
192 events.map((event) =>
193 // "ON_DEPLOYMENT_START" -> "OnDeploymentStart" (payload `Type`)
194 event
195 .toLowerCase()
196 .split("_")
197 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
198 .join(""),
199 ),
200 );
201 return (event: any) => {
202 if (
203 isAppConfigDeploymentEvent(event) &&
204 subscribed.has(event.Type) &&
205 (isEnvironment(target)
206 ? event.Environment?.Id === targetId
207 : event.Application?.Id === targetId)
208 ) {
209 return process(Stream.succeed(event)).pipe(Effect.orDie);
210 }
211 };
212 }),
213 );
214 }) as DeploymentEventSourceService;
215 }),
216);