| 1 | import * as config from "@distilled.cloud/aws/config-service"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | |
| 10 | export interface DeliveryChannelProps { |
| 11 | /** |
| 12 | * Name of the delivery channel. AWS allows only ONE delivery channel per |
| 13 | * account per region. Changing the name replaces the channel. |
| 14 | * @default ${app}-${stage}-${id} |
| 15 | */ |
| 16 | name?: string; |
| 17 | /** |
| 18 | * Name of the S3 bucket AWS Config delivers configuration snapshots and |
| 19 | * history files to. The bucket needs a policy granting |
| 20 | * `config.amazonaws.com` `s3:GetBucketAcl` on the bucket and |
| 21 | * `s3:PutObject` on `arn:aws:s3:::{bucket}/AWSLogs/{account}/Config/*`. |
| 22 | */ |
| 23 | s3BucketName: string; |
| 24 | /** |
| 25 | * Key prefix for the delivered objects. |
| 26 | */ |
| 27 | s3KeyPrefix?: string; |
| 28 | /** |
| 29 | * ARN of the KMS key AWS Config uses to encrypt objects delivered to the |
| 30 | * S3 bucket. |
| 31 | */ |
| 32 | s3KmsKeyArn?: string; |
| 33 | /** |
| 34 | * ARN of the SNS topic AWS Config sends notifications to. |
| 35 | */ |
| 36 | snsTopicArn?: string; |
| 37 | /** |
| 38 | * How often AWS Config delivers configuration snapshots to the bucket. |
| 39 | */ |
| 40 | snapshotDeliveryFrequency?: |
| 41 | | "One_Hour" |
| 42 | | "Three_Hours" |
| 43 | | "Six_Hours" |
| 44 | | "Twelve_Hours" |
| 45 | | "TwentyFour_Hours"; |
| 46 | } |
| 47 | |
| 48 | export interface DeliveryChannel extends Resource< |
| 49 | "AWS.Config.DeliveryChannel", |
| 50 | DeliveryChannelProps, |
| 51 | { |
| 52 | /** Physical name of the delivery channel. */ |
| 53 | deliveryChannelName: string; |
| 54 | /** S3 bucket configuration snapshots and history are delivered to. */ |
| 55 | s3BucketName: string; |
| 56 | }, |
| 57 | never, |
| 58 | Providers |
| 59 | > {} |
| 60 | |
| 61 | /** |
| 62 | * The AWS Config delivery channel that delivers configuration snapshots and |
| 63 | * configuration history to an S3 bucket (and optionally notifies an SNS |
| 64 | * topic). |
| 65 | * |
| 66 | * AWS allows only **one** delivery channel per account per region — treat |
| 67 | * this resource as an account-region singleton. A configuration recorder |
| 68 | * must exist before the channel can be created (see |
| 69 | * `AWS.Config.ConfigurationRecorder`). |
| 70 | * ### Creating the Channel |
| 71 | * **Example:** Deliver configuration history to S3 |
| 72 | * ```typescript |
| 73 | * import * as Config from "alchemy/AWS/Config"; |
| 74 | * |
| 75 | * const channel = yield* Config.DeliveryChannel("Channel", { |
| 76 | * s3BucketName: bucket.bucketName, |
| 77 | * }); |
| 78 | * ``` |
| 79 | * |
| 80 | * **Example:** Periodic snapshots with a key prefix |
| 81 | * ```typescript |
| 82 | * const channel = yield* Config.DeliveryChannel("Channel", { |
| 83 | * s3BucketName: bucket.bucketName, |
| 84 | * s3KeyPrefix: "config", |
| 85 | * snapshotDeliveryFrequency: "TwentyFour_Hours", |
| 86 | * }); |
| 87 | * ``` |
| 88 | * |
| 89 | * @resource |
| 90 | */ |
| 91 | export const DeliveryChannel = Resource<DeliveryChannel>( |
| 92 | "AWS.Config.DeliveryChannel", |
| 93 | ); |
| 94 | |
| 95 | /** |
| 96 | * `PutDeliveryChannel` validates the recorder's existence and the bucket |
| 97 | * policy at call time. A recorder or bucket policy created in the same |
| 98 | * deploy can take a few seconds to become visible, surfacing as |
| 99 | * `NoAvailableConfigurationRecorderException` / |
| 100 | * `InsufficientDeliveryPolicyException` / `NoSuchBucketException` — retry |
| 101 | * on a bounded schedule (~40s). |
| 102 | * |
| 103 | * Explicitly typed: inlining `Effect.retry` with options in provider |
| 104 | * lifecycle code can widen the provider layer to `unknown` in declaration |
| 105 | * emit. |
| 106 | * |
| 107 | * @internal |
| 108 | */ |
| 109 | const retryChannelPut = <A, E extends { readonly _tag: string }, R>( |
| 110 | self: Effect.Effect<A, E, R>, |
| 111 | ): Effect.Effect<A, E, R> => |
| 112 | Effect.retry(self, { |
| 113 | while: (e) => |
| 114 | e._tag === "NoAvailableConfigurationRecorderException" || |
| 115 | e._tag === "InsufficientDeliveryPolicyException" || |
| 116 | e._tag === "NoSuchBucketException", |
| 117 | schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]), |
| 118 | }); |
| 119 | |
| 120 | /** |
| 121 | * Deleting the channel while the recorder is still running (or while the |
| 122 | * recorder is being stopped/deleted concurrently by the same destroy) |
| 123 | * rejects with `LastDeliveryChannelDeleteFailedException` — a dependency |
| 124 | * violation, retried on a bounded schedule (~40s). |
| 125 | * @internal |
| 126 | */ |
| 127 | const retryChannelDelete = <A, E extends { readonly _tag: string }, R>( |
| 128 | self: Effect.Effect<A, E, R>, |
| 129 | ): Effect.Effect<A, E, R> => |
| 130 | Effect.retry(self, { |
| 131 | while: (e) => e._tag === "LastDeliveryChannelDeleteFailedException", |
| 132 | schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]), |
| 133 | }); |
| 134 | |
| 135 | export const DeliveryChannelProvider = () => |
| 136 | Provider.effect( |
| 137 | DeliveryChannel, |
| 138 | Effect.gen(function* () { |
| 139 | const createChannelName = Effect.fn(function* ( |
| 140 | id: string, |
| 141 | props: Pick<DeliveryChannelProps, "name">, |
| 142 | ) { |
| 143 | return ( |
| 144 | props.name ?? (yield* createPhysicalName({ id, maxLength: 256 })) |
| 145 | ); |
| 146 | }); |
| 147 | |
| 148 | const toWireChannel = ( |
| 149 | name: string, |
| 150 | props: DeliveryChannelProps, |
| 151 | ): config.DeliveryChannel => ({ |
| 152 | name, |
| 153 | s3BucketName: props.s3BucketName, |
| 154 | s3KeyPrefix: props.s3KeyPrefix, |
| 155 | s3KmsKeyArn: props.s3KmsKeyArn, |
| 156 | snsTopicARN: props.snsTopicArn, |
| 157 | configSnapshotDeliveryProperties: |
| 158 | props.snapshotDeliveryFrequency === undefined |
| 159 | ? undefined |
| 160 | : { deliveryFrequency: props.snapshotDeliveryFrequency }, |
| 161 | }); |
| 162 | |
| 163 | const observeChannel = Effect.fn(function* (name: string) { |
| 164 | const response = yield* config |
| 165 | .describeDeliveryChannels({ DeliveryChannelNames: [name] }) |
| 166 | .pipe( |
| 167 | Effect.catchTag("NoSuchDeliveryChannelException", () => |
| 168 | Effect.succeed({ DeliveryChannels: [] }), |
| 169 | ), |
| 170 | ); |
| 171 | return (response.DeliveryChannels ?? []).at(0); |
| 172 | }); |
| 173 | |
| 174 | return DeliveryChannel.Provider.of({ |
| 175 | stables: ["deliveryChannelName"], |
| 176 | list: () => |
| 177 | config.describeDeliveryChannels({}).pipe( |
| 178 | Effect.map((response) => |
| 179 | (response.DeliveryChannels ?? []).flatMap((channel) => |
| 180 | channel.name && channel.s3BucketName |
| 181 | ? [ |
| 182 | { |
| 183 | deliveryChannelName: channel.name, |
| 184 | s3BucketName: channel.s3BucketName, |
| 185 | }, |
| 186 | ] |
| 187 | : [], |
| 188 | ), |
| 189 | ), |
| 190 | ), |
| 191 | // Delivery channels are not taggable, so there is no ownership |
| 192 | // marker to check — an existing channel with our derived name is |
| 193 | // treated as ours. |
| 194 | read: Effect.fn(function* ({ id, olds, output }) { |
| 195 | const name = |
| 196 | output?.deliveryChannelName ?? |
| 197 | (yield* createChannelName(id, olds ?? {})); |
| 198 | const channel = yield* observeChannel(name); |
| 199 | if (channel?.name === undefined) return undefined; |
| 200 | return { |
| 201 | deliveryChannelName: channel.name, |
| 202 | s3BucketName: channel.s3BucketName!, |
| 203 | }; |
| 204 | }), |
| 205 | diff: Effect.fn(function* ({ id, news, olds }) { |
| 206 | if (!isResolved(news)) return undefined; |
| 207 | const oldName = yield* createChannelName(id, olds ?? {}); |
| 208 | const newName = yield* createChannelName(id, news); |
| 209 | if (oldName !== newName) { |
| 210 | return { action: "replace" } as const; |
| 211 | } |
| 212 | // fall through: engine default update logic for mutable fields |
| 213 | }), |
| 214 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 215 | const name = |
| 216 | output?.deliveryChannelName ?? (yield* createChannelName(id, news)); |
| 217 | const desired = toWireChannel(name, news); |
| 218 | |
| 219 | // 1. OBSERVE — cloud state is authoritative. |
| 220 | const observed = yield* observeChannel(name); |
| 221 | |
| 222 | // 2+3. ENSURE + SYNC — PutDeliveryChannel is a full upsert; apply |
| 223 | // when missing or when any declared aspect drifted. Comparing |
| 224 | // the full wire shape is safe here: AWS echoes exactly the |
| 225 | // members that were put (no server-side defaults). |
| 226 | const inSync = |
| 227 | observed !== undefined && |
| 228 | observed.s3BucketName === desired.s3BucketName && |
| 229 | (observed.s3KeyPrefix ?? undefined) === desired.s3KeyPrefix && |
| 230 | (observed.s3KmsKeyArn ?? undefined) === desired.s3KmsKeyArn && |
| 231 | (observed.snsTopicARN ?? undefined) === desired.snsTopicARN && |
| 232 | (observed.configSnapshotDeliveryProperties?.deliveryFrequency ?? |
| 233 | undefined) === |
| 234 | desired.configSnapshotDeliveryProperties?.deliveryFrequency; |
| 235 | if (!inSync) { |
| 236 | yield* retryChannelPut( |
| 237 | config.putDeliveryChannel({ DeliveryChannel: desired }), |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | yield* session.note(name); |
| 242 | return { |
| 243 | deliveryChannelName: name, |
| 244 | s3BucketName: news.s3BucketName, |
| 245 | }; |
| 246 | }), |
| 247 | delete: Effect.fn(function* ({ output }) { |
| 248 | yield* retryChannelDelete( |
| 249 | config.deleteDeliveryChannel({ |
| 250 | DeliveryChannelName: output.deliveryChannelName, |
| 251 | }), |
| 252 | ).pipe( |
| 253 | Effect.catchTag( |
| 254 | "NoSuchDeliveryChannelException", |
| 255 | () => Effect.void, |
| 256 | ), |
| 257 | ); |
| 258 | }), |
| 259 | }); |
| 260 | }), |
| 261 | ); |