1import * as config from "@distilled.cloud/aws/config-service";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasAlchemyTags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export interface RecordingGroupProps {
18 /**
19 * Record every supported (regional) resource type. Mutually exclusive
20 * with `resourceTypes`.
21 * @default false
22 */
23 allSupported?: boolean;
24 /**
25 * Also record global resource types (e.g. IAM). Only meaningful with
26 * `allSupported: true`.
27 * @default false
28 */
29 includeGlobalResourceTypes?: boolean;
30 /**
31 * The specific resource types to record, e.g. `AWS::S3::Bucket`. Only
32 * meaningful when `allSupported` is `false`.
33 */
34 resourceTypes?: string[];
35 /**
36 * Resource types to exclude from recording. Requires
37 * `recordingStrategy.useOnly: "EXCLUSION_BY_RESOURCE_TYPES"`.
38 */
39 exclusionByResourceTypes?: {
40 /**
41 * The resource types excluded from recording.
42 */
43 resourceTypes: string[];
44 };
45 /**
46 * Which recording strategy the recorder uses.
47 */
48 recordingStrategy?: {
49 /**
50 * `ALL_SUPPORTED_RESOURCE_TYPES`, `INCLUSION_BY_RESOURCE_TYPES`, or
51 * `EXCLUSION_BY_RESOURCE_TYPES`.
52 */
53 useOnly?:
54 | "ALL_SUPPORTED_RESOURCE_TYPES"
55 | "INCLUSION_BY_RESOURCE_TYPES"
56 | "EXCLUSION_BY_RESOURCE_TYPES";
57 };
58}
59
60export interface RecordingModeProps {
61 /**
62 * The default recording frequency for all recorded resource types.
63 */
64 recordingFrequency: "CONTINUOUS" | "DAILY";
65 /**
66 * Per-resource-type overrides of the recording frequency.
67 */
68 recordingModeOverrides?: {
69 /**
70 * Description of the override.
71 */
72 description?: string;
73 /**
74 * The resource types the override applies to.
75 */
76 resourceTypes: string[];
77 /**
78 * The recording frequency for the overridden resource types.
79 */
80 recordingFrequency: "CONTINUOUS" | "DAILY";
81 }[];
82}
83
84export interface ConfigurationRecorderProps {
85 /**
86 * Name of the configuration recorder. AWS allows only ONE customer
87 * managed configuration recorder per account per region. Changing the
88 * name replaces the recorder.
89 * @default ${app}-${stage}-${id}
90 */
91 name?: string;
92 /**
93 * ARN of the IAM role the recorder assumes to read your resources'
94 * configurations, e.g. the `AWSServiceRoleForConfig` service-linked role
95 * (`arn:aws:iam::{account}:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig`).
96 */
97 roleArn: string;
98 /**
99 * Which resource types the recorder records.
100 * @default all supported resource types
101 */
102 recordingGroup?: RecordingGroupProps;
103 /**
104 * How frequently the recorder records configuration changes.
105 * @default CONTINUOUS
106 */
107 recordingMode?: RecordingModeProps;
108 /**
109 * Desired recording state. `true` starts the recorder (requires a
110 * delivery channel), `false` stops it. When omitted the recording state
111 * is left untouched.
112 */
113 recording?: boolean;
114 /**
115 * Tags to apply to the recorder. Merged with internal Alchemy tags.
116 */
117 tags?: Record<string, string>;
118}
119
120export interface ConfigurationRecorder extends Resource<
121 "AWS.Config.ConfigurationRecorder",
122 ConfigurationRecorderProps,
123 {
124 /** Physical name of the configuration recorder. */
125 recorderName: string;
126 /** ARN of the configuration recorder. */
127 recorderArn: string;
128 },
129 never,
130 Providers
131> {}
132
133/**
134 * The AWS Config configuration recorder that detects and records changes to
135 * your AWS resource configurations.
136 *
137 * AWS allows only **one** customer managed configuration recorder per
138 * account per region — treat this resource as an account-region singleton.
139 * Starting the recorder (`recording: true`) requires a delivery channel
140 * (see `AWS.Config.DeliveryChannel`) and incurs per-configuration-item
141 * charges.
142 * ### Creating the Recorder
143 * **Example:** Recorder with the Config service-linked role
144 * ```typescript
145 * import * as Config from "alchemy/AWS/Config";
146 *
147 * const recorder = yield* Config.ConfigurationRecorder("Recorder", {
148 * roleArn: `arn:aws:iam::${accountId}:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig`,
149 * recordingGroup: { allSupported: true },
150 * });
151 * ```
152 *
153 * **Example:** Record only specific resource types
154 * ```typescript
155 * const recorder = yield* Config.ConfigurationRecorder("Recorder", {
156 * roleArn: serviceLinkedRoleArn,
157 * recordingGroup: {
158 * resourceTypes: ["AWS::S3::Bucket", "AWS::EC2::SecurityGroup"],
159 * },
160 * });
161 * ```
162 *
163 * ### Recording State
164 * **Example:** Start recording (requires a delivery channel)
165 * ```typescript
166 * const channel = yield* Config.DeliveryChannel("Channel", {
167 * s3BucketName: bucket.bucketName,
168 * });
169 * const recorder = yield* Config.ConfigurationRecorder("Recorder", {
170 * roleArn: serviceLinkedRoleArn,
171 * recording: true,
172 * });
173 * ```
174 *
175 * @resource
176 */
177export const ConfigurationRecorder = Resource<ConfigurationRecorder>(
178 "AWS.Config.ConfigurationRecorder",
179);
180
181/**
182 * A recorder role that was just created can be transiently rejected with
183 * `InvalidRoleException` until IAM propagates. Bounded retry (~40s).
184 *
185 * Explicitly typed: inlining `Effect.retry` with options in provider
186 * lifecycle code can widen the provider layer to `unknown` in declaration
187 * emit.
188 *
189 * @internal
190 */
191const retryWhileInvalidRole = <A, E extends { readonly _tag: string }, R>(
192 self: Effect.Effect<A, E, R>,
193): Effect.Effect<A, E, R> =>
194 Effect.retry(self, {
195 while: (e) => e._tag === "InvalidRoleException",
196 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
197 });
198
199export const ConfigurationRecorderProvider = () =>
200 Provider.effect(
201 ConfigurationRecorder,
202 Effect.gen(function* () {
203 const createRecorderName = Effect.fn(function* (
204 id: string,
205 props: Pick<ConfigurationRecorderProps, "name">,
206 ) {
207 return (
208 props.name ?? (yield* createPhysicalName({ id, maxLength: 256 }))
209 );
210 });
211
212 const toWireRecordingGroup = (
213 group: RecordingGroupProps | undefined,
214 ): config.RecordingGroup | undefined =>
215 group === undefined
216 ? undefined
217 : {
218 allSupported: group.allSupported,
219 includeGlobalResourceTypes: group.includeGlobalResourceTypes,
220 resourceTypes: group.resourceTypes,
221 exclusionByResourceTypes: group.exclusionByResourceTypes,
222 recordingStrategy: group.recordingStrategy,
223 };
224
225 const toWireRecordingMode = (
226 mode: RecordingModeProps | undefined,
227 ): config.RecordingMode | undefined =>
228 mode === undefined
229 ? undefined
230 : {
231 recordingFrequency: mode.recordingFrequency,
232 recordingModeOverrides: mode.recordingModeOverrides,
233 };
234
235 const observeRecorder = Effect.fn(function* (name: string) {
236 const response = yield* config
237 .describeConfigurationRecorders({
238 ConfigurationRecorderNames: [name],
239 })
240 .pipe(
241 Effect.catchTag("NoSuchConfigurationRecorderException", () =>
242 Effect.succeed({ ConfigurationRecorders: [] }),
243 ),
244 );
245 return (response.ConfigurationRecorders ?? []).at(0);
246 });
247
248 const observedTags = (arn: string) =>
249 config.listTagsForResource({ ResourceArn: arn }).pipe(
250 Effect.map((r) =>
251 Object.fromEntries(
252 (r.Tags ?? []).flatMap((t) =>
253 t.Key !== undefined ? [[t.Key, t.Value ?? ""]] : [],
254 ),
255 ),
256 ),
257 Effect.catchTag("ResourceNotFoundException", () =>
258 Effect.succeed({} as Record<string, string>),
259 ),
260 );
261
262 return ConfigurationRecorder.Provider.of({
263 stables: ["recorderName", "recorderArn"],
264 list: () =>
265 config.describeConfigurationRecorders({}).pipe(
266 Effect.map((response) =>
267 (response.ConfigurationRecorders ?? []).flatMap((recorder) =>
268 recorder.name && recorder.arn
269 ? [
270 {
271 recorderName: recorder.name,
272 recorderArn: recorder.arn,
273 },
274 ]
275 : [],
276 ),
277 ),
278 ),
279 read: Effect.fn(function* ({ id, olds, output }) {
280 const name =
281 output?.recorderName ?? (yield* createRecorderName(id, olds ?? {}));
282 const recorder = yield* observeRecorder(name);
283 if (recorder?.arn === undefined) return undefined;
284 const attrs = { recorderName: name, recorderArn: recorder.arn };
285 const tags = yield* observedTags(recorder.arn);
286 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
287 }),
288 diff: Effect.fn(function* ({ id, news, olds }) {
289 if (!isResolved(news)) return undefined;
290 const oldName = yield* createRecorderName(id, olds ?? {});
291 const newName = yield* createRecorderName(id, news);
292 if (oldName !== newName) {
293 return { action: "replace" } as const;
294 }
295 // fall through: engine default update logic for mutable fields
296 }),
297 reconcile: Effect.fn(function* ({ id, news, output, session }) {
298 const name =
299 output?.recorderName ?? (yield* createRecorderName(id, news));
300 const internalTags = yield* createInternalTags(id);
301 const desiredTags = { ...news.tags, ...internalTags };
302 const desiredGroup = toWireRecordingGroup(news.recordingGroup);
303 const desiredMode = toWireRecordingMode(news.recordingMode);
304
305 // 1. OBSERVE — cloud state is authoritative.
306 const observed = yield* observeRecorder(name);
307
308 // 2+3. ENSURE + SYNC — PutConfigurationRecorder is a full upsert;
309 // apply when missing or when a user-declared aspect drifted
310 // (fields AWS defaults are only compared when declared).
311 const inSync =
312 observed !== undefined &&
313 observed.roleARN === news.roleArn &&
314 (desiredGroup === undefined ||
315 JSON.stringify(observed.recordingGroup) ===
316 JSON.stringify({
317 ...observed.recordingGroup,
318 ...desiredGroup,
319 })) &&
320 (desiredMode === undefined ||
321 JSON.stringify(observed.recordingMode) ===
322 JSON.stringify({ ...observed.recordingMode, ...desiredMode }));
323 if (!inSync) {
324 yield* retryWhileInvalidRole(
325 config.putConfigurationRecorder({
326 ConfigurationRecorder: {
327 name,
328 roleARN: news.roleArn,
329 recordingGroup: desiredGroup,
330 recordingMode: desiredMode,
331 },
332 Tags: createTagsList(desiredTags),
333 }),
334 );
335 }
336
337 // Re-observe for the ARN (Put returns an empty body).
338 const live = yield* observeRecorder(name);
339 const recorderArn = live?.arn ?? output?.recorderArn;
340
341 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
342 if (recorderArn !== undefined) {
343 const currentTags = yield* observedTags(recorderArn);
344 const { upsert, removed } = diffTags(currentTags, desiredTags);
345 if (upsert.length > 0) {
346 yield* config.tagResource({
347 ResourceArn: recorderArn,
348 Tags: upsert,
349 });
350 }
351 if (removed.length > 0) {
352 yield* config.untagResource({
353 ResourceArn: recorderArn,
354 TagKeys: removed,
355 });
356 }
357 }
358
359 // 3c. SYNC RECORDING STATE — only when the user declared a desired
360 // state; diff against the OBSERVED status.
361 if (news.recording !== undefined) {
362 const status = yield* config
363 .describeConfigurationRecorderStatus({
364 ConfigurationRecorderNames: [name],
365 })
366 .pipe(
367 Effect.map(
368 (r) =>
369 (r.ConfigurationRecordersStatus ?? []).at(0)?.recording ??
370 false,
371 ),
372 Effect.catchTag("NoSuchConfigurationRecorderException", () =>
373 Effect.succeed(false),
374 ),
375 );
376 if (news.recording && !status) {
377 yield* config.startConfigurationRecorder({
378 ConfigurationRecorderName: name,
379 });
380 } else if (!news.recording && status) {
381 yield* config.stopConfigurationRecorder({
382 ConfigurationRecorderName: name,
383 });
384 }
385 }
386
387 yield* session.note(name);
388 return { recorderName: name, recorderArn: recorderArn! };
389 }),
390 delete: Effect.fn(function* ({ output }) {
391 // A recorder must be stopped before it can be deleted; both calls
392 // treat an already-gone recorder as success.
393 yield* config
394 .stopConfigurationRecorder({
395 ConfigurationRecorderName: output.recorderName,
396 })
397 .pipe(
398 Effect.catchTag(
399 "NoSuchConfigurationRecorderException",
400 () => Effect.void,
401 ),
402 );
403 yield* config
404 .deleteConfigurationRecorder({
405 ConfigurationRecorderName: output.recorderName,
406 })
407 .pipe(
408 Effect.catchTag(
409 "NoSuchConfigurationRecorderException",
410 () => Effect.void,
411 ),
412 );
413 }),
414 });
415 }),
416 );