| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | |
| 5 | import * as Namespace from "../../Namespace.ts"; |
| 6 | import type { Application } from "../AppConfig/Application.ts"; |
| 7 | import { |
| 8 | DeploymentEventSource as AppConfigDeploymentEventSourceContract, |
| 9 | type DeploymentEventRecord, |
| 10 | type DeploymentEventSourceProps, |
| 11 | type DeploymentEventSourceService, |
| 12 | type DeploymentEventType, |
| 13 | } from "../AppConfig/DeploymentEventSource.ts"; |
| 14 | import type { Environment } from "../AppConfig/Environment.ts"; |
| 15 | import { Extension } from "../AppConfig/Extension.ts"; |
| 16 | import { ExtensionAssociation } from "../AppConfig/ExtensionAssociation.ts"; |
| 17 | import { Role } from "../IAM/Role.ts"; |
| 18 | import * as Lambda from "./Function.ts"; |
| 19 | |
| 20 | const 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. */ |
| 29 | const 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 | */ |
| 46 | export 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 | |
| 53 | const 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 | */ |
| 87 | export 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 | ); |