1import * as config from "@distilled.cloud/aws/config-service";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export 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
48export 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 */
91export 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 */
109const 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 */
127const 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
135export 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 );