1import * as config from "@distilled.cloud/aws/config-service";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createInternalTags,
13 createTagsList,
14 diffTags,
15 hasAlchemyTags,
16} from "../../Tags.ts";
17import type { Providers } from "../Providers.ts";
18
19/**
20 * How often AWS Config runs evaluations for a periodic rule.
21 */
22export type ConfigMaximumExecutionFrequency =
23 | "One_Hour"
24 | "Three_Hours"
25 | "Six_Hours"
26 | "Twelve_Hours"
27 | "TwentyFour_Hours";
28
29export interface ConfigRuleSourceDetail {
30 /**
31 * The source of the event that triggers evaluation.
32 * @default "aws.config"
33 */
34 eventSource?: string;
35 /**
36 * The type of notification that triggers evaluation, e.g.
37 * `ConfigurationItemChangeNotification` or `ScheduledNotification`.
38 */
39 messageType?: string;
40 /**
41 * The frequency at which the rule is evaluated when the message type is
42 * `ScheduledNotification`.
43 */
44 maximumExecutionFrequency?: ConfigMaximumExecutionFrequency;
45}
46
47export interface ConfigRuleSource {
48 /**
49 * Who owns and maintains the rule logic. `AWS` for managed rules,
50 * `CUSTOM_LAMBDA` for Lambda-backed custom rules, `CUSTOM_POLICY` for
51 * Guard custom-policy rules.
52 */
53 owner: "AWS" | "CUSTOM_LAMBDA" | "CUSTOM_POLICY";
54 /**
55 * For managed rules, the rule identifier, e.g.
56 * `S3_BUCKET_VERSIONING_ENABLED`. For custom Lambda rules, the ARN of the
57 * rule's Lambda function.
58 */
59 sourceIdentifier?: string;
60 /**
61 * The source and type of events that trigger evaluation. Required for
62 * custom Lambda rules; not used for managed rules.
63 */
64 sourceDetails?: ConfigRuleSourceDetail[];
65 /**
66 * Guard policy details for `CUSTOM_POLICY` rules.
67 */
68 customPolicyDetails?: {
69 /**
70 * The runtime system for the Guard policy, e.g. `guard-2.x.x`.
71 */
72 policyRuntime: string;
73 /**
74 * The Guard policy text.
75 */
76 policyText: string;
77 /**
78 * Whether to log Guard debug output.
79 * @default false
80 */
81 enableDebugLogDelivery?: boolean;
82 };
83}
84
85export interface ConfigRuleScope {
86 /**
87 * Resource types the rule evaluates, e.g. `AWS::S3::Bucket`.
88 */
89 complianceResourceTypes?: string[];
90 /**
91 * Tag key applied to resources the rule evaluates.
92 */
93 tagKey?: string;
94 /**
95 * Tag value applied to resources the rule evaluates. Requires `tagKey`.
96 */
97 tagValue?: string;
98 /**
99 * The ID of a single resource the rule evaluates. Requires exactly one
100 * entry in `complianceResourceTypes`.
101 */
102 complianceResourceId?: string;
103}
104
105export interface ConfigRuleProps {
106 /**
107 * Name of the Config rule. Changing the name replaces the rule.
108 * @default ${app}-${stage}-${id}
109 */
110 configRuleName?: string;
111 /**
112 * Description of the rule.
113 */
114 description?: string;
115 /**
116 * The rule's source: owner (AWS-managed, custom Lambda, or Guard custom
117 * policy) plus the identifier of the rule logic.
118 */
119 source: ConfigRuleSource;
120 /**
121 * Input parameters passed to the rule's evaluation logic, serialized to
122 * JSON.
123 */
124 inputParameters?: Record<string, string>;
125 /**
126 * The maximum frequency at which AWS Config runs evaluations for a
127 * periodic rule.
128 */
129 maximumExecutionFrequency?: ConfigMaximumExecutionFrequency;
130 /**
131 * Restricts the rule to a subset of resources by type, tag, or ID. When
132 * omitted, evaluations are triggered for all supported resources.
133 */
134 scope?: ConfigRuleScope;
135 /**
136 * The evaluation modes the rule runs in.
137 * @default ["DETECTIVE"]
138 */
139 evaluationModes?: ("DETECTIVE" | "PROACTIVE")[];
140 /**
141 * Tags to apply to the rule. Merged with internal Alchemy tags.
142 */
143 tags?: Record<string, string>;
144}
145
146export interface ConfigRule extends Resource<
147 "AWS.Config.ConfigRule",
148 ConfigRuleProps,
149 {
150 /** Physical name of the config rule. */
151 configRuleName: string;
152 /** ARN of the config rule. */
153 configRuleArn: string;
154 /** Unique AWS-assigned rule ID. */
155 configRuleId: string;
156 },
157 never,
158 Providers
159> {}
160
161/**
162 * An AWS Config rule that evaluates whether your AWS resources comply with
163 * a desired configuration — either an AWS-managed rule, a custom Lambda
164 * rule, or a Guard custom-policy rule.
165 *
166 * The account/region must have an AWS Config configuration recorder before
167 * rules can be created (see `AWS.Config.ConfigurationRecorder`).
168 * ### Creating Rules
169 * **Example:** AWS-managed rule
170 * ```typescript
171 * import * as Config from "alchemy/AWS/Config";
172 *
173 * const rule = yield* Config.ConfigRule("BucketVersioning", {
174 * source: {
175 * owner: "AWS",
176 * sourceIdentifier: "S3_BUCKET_VERSIONING_ENABLED",
177 * },
178 * });
179 * ```
180 *
181 * **Example:** Managed rule with input parameters and scope
182 * ```typescript
183 * const rule = yield* Config.ConfigRule("RequiredTags", {
184 * description: "All buckets must carry a team tag",
185 * source: { owner: "AWS", sourceIdentifier: "REQUIRED_TAGS" },
186 * inputParameters: { tag1Key: "team" },
187 * scope: { complianceResourceTypes: ["AWS::S3::Bucket"] },
188 * });
189 * ```
190 *
191 * ### Periodic Evaluation
192 * **Example:** Evaluate on a schedule
193 * ```typescript
194 * const rule = yield* Config.ConfigRule("RootMfa", {
195 * source: {
196 * owner: "AWS",
197 * sourceIdentifier: "ROOT_ACCOUNT_MFA_ENABLED",
198 * },
199 * maximumExecutionFrequency: "TwentyFour_Hours",
200 * });
201 * ```
202 *
203 * @resource
204 */
205export const ConfigRule = Resource<ConfigRule>("AWS.Config.ConfigRule");
206
207/**
208 * Raised when a Config rule that was just written does not become visible
209 * to `DescribeConfigRules` within the reconciler's bounded wait.
210 */
211export class ConfigRuleNotVisible extends Data.TaggedError(
212 "ConfigRuleNotVisible",
213)<{ message: string }> {}
214
215/**
216 * `PutConfigRule`/`DeleteConfigRule` reject with `ResourceInUseException`
217 * while a rule is in the `DELETING` state (rule deletion is asynchronous).
218 * Retry it on a bounded schedule (~40s).
219 *
220 * Explicitly typed: inlining `Effect.retry` with options in provider
221 * lifecycle code can widen the provider layer to `unknown` in declaration
222 * emit.
223 *
224 * @internal
225 */
226const retryWhileRuleInUse = <A, E extends { readonly _tag: string }, R>(
227 self: Effect.Effect<A, E, R>,
228): Effect.Effect<A, E, R> =>
229 Effect.retry(self, {
230 while: (e) => e._tag === "ResourceInUseException",
231 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
232 });
233
234/**
235 * A freshly-put rule can take a moment to become visible to
236 * `DescribeConfigRules`. Bounded retry (~10s).
237 * @internal
238 */
239const retryWhileNotVisible = <A, E extends { readonly _tag: string }, R>(
240 self: Effect.Effect<A, E, R>,
241): Effect.Effect<A, E, R> =>
242 Effect.retry(self, {
243 while: (e) => e._tag === "ConfigRuleNotVisible",
244 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(10)]),
245 });
246
247/**
248 * Canonical JSON for structural comparison: recursively sorts object keys
249 * and drops `undefined` members so wire objects with different key orders
250 * compare equal.
251 * @internal
252 */
253const canonical = (value: unknown): string => {
254 const sortKeys = (v: unknown): unknown => {
255 if (Array.isArray(v)) return v.map(sortKeys);
256 if (v !== null && typeof v === "object") {
257 return Object.fromEntries(
258 Object.entries(v as Record<string, unknown>)
259 .filter(([, member]) => member !== undefined)
260 .sort(([a], [b]) => a.localeCompare(b))
261 .map(([k, member]) => [k, sortKeys(member)]),
262 );
263 }
264 return v ?? null;
265 };
266 return JSON.stringify(sortKeys(value));
267};
268
269/** @internal */
270const parseParams = (params: string | undefined): Record<string, string> =>
271 params === undefined || params === "" ? {} : JSON.parse(params);
272
273export const ConfigRuleProvider = () =>
274 Provider.effect(
275 ConfigRule,
276 Effect.gen(function* () {
277 const createRuleName = Effect.fn(function* (
278 id: string,
279 props: Pick<ConfigRuleProps, "configRuleName">,
280 ) {
281 return (
282 props.configRuleName ??
283 (yield* createPhysicalName({ id, maxLength: 128 }))
284 );
285 });
286
287 const toWireRule = (
288 name: string,
289 props: ConfigRuleProps,
290 ): config.ConfigRule => ({
291 ConfigRuleName: name,
292 Description: props.description,
293 Source: {
294 Owner: props.source.owner,
295 SourceIdentifier: props.source.sourceIdentifier,
296 SourceDetails: props.source.sourceDetails?.map((d) => ({
297 EventSource: d.eventSource ?? "aws.config",
298 MessageType: d.messageType,
299 MaximumExecutionFrequency: d.maximumExecutionFrequency,
300 })),
301 CustomPolicyDetails: props.source.customPolicyDetails
302 ? {
303 PolicyRuntime: props.source.customPolicyDetails.policyRuntime,
304 PolicyText: props.source.customPolicyDetails.policyText,
305 EnableDebugLogDelivery:
306 props.source.customPolicyDetails.enableDebugLogDelivery,
307 }
308 : undefined,
309 },
310 InputParameters: props.inputParameters
311 ? JSON.stringify(props.inputParameters)
312 : undefined,
313 MaximumExecutionFrequency: props.maximumExecutionFrequency,
314 Scope: props.scope
315 ? {
316 ComplianceResourceTypes: props.scope.complianceResourceTypes,
317 TagKey: props.scope.tagKey,
318 TagValue: props.scope.tagValue,
319 ComplianceResourceId: props.scope.complianceResourceId,
320 }
321 : undefined,
322 EvaluationModes: props.evaluationModes?.map((mode) => ({
323 Mode: mode,
324 })),
325 });
326
327 // A rule is in sync when every aspect the user controls matches the
328 // OBSERVED cloud state. Fields AWS defaults when unspecified
329 // (EvaluationModes, SourceDetails) are only compared when the user
330 // declared them, so an omitted prop never causes perpetual drift.
331 const ruleInSync = (
332 observed: config.ConfigRule,
333 desired: config.ConfigRule,
334 ): boolean =>
335 (observed.Description ?? undefined) === desired.Description &&
336 (observed.MaximumExecutionFrequency ?? undefined) ===
337 desired.MaximumExecutionFrequency &&
338 canonical(parseParams(observed.InputParameters)) ===
339 canonical(parseParams(desired.InputParameters)) &&
340 canonical(observed.Scope) === canonical(desired.Scope) &&
341 observed.Source.Owner === desired.Source.Owner &&
342 (observed.Source.SourceIdentifier ?? undefined) ===
343 desired.Source.SourceIdentifier &&
344 (desired.Source.SourceDetails === undefined ||
345 canonical(observed.Source.SourceDetails) ===
346 canonical(desired.Source.SourceDetails)) &&
347 (desired.Source.CustomPolicyDetails === undefined ||
348 canonical(observed.Source.CustomPolicyDetails) ===
349 canonical(desired.Source.CustomPolicyDetails)) &&
350 (desired.EvaluationModes === undefined ||
351 canonical(observed.EvaluationModes) ===
352 canonical(desired.EvaluationModes));
353
354 const observeRule = Effect.fn(function* (ruleName: string) {
355 const response = yield* config
356 .describeConfigRules({ ConfigRuleNames: [ruleName] })
357 .pipe(
358 Effect.catchTag("NoSuchConfigRuleException", () =>
359 Effect.succeed({ ConfigRules: [] }),
360 ),
361 );
362 return (response.ConfigRules ?? []).at(0);
363 });
364
365 const observedTags = (arn: string) =>
366 config.listTagsForResource({ ResourceArn: arn }).pipe(
367 Effect.map((r) =>
368 Object.fromEntries(
369 (r.Tags ?? []).flatMap((t) =>
370 t.Key !== undefined ? [[t.Key, t.Value ?? ""]] : [],
371 ),
372 ),
373 ),
374 Effect.catchTag("ResourceNotFoundException", () =>
375 Effect.succeed({} as Record<string, string>),
376 ),
377 );
378
379 const toAttrs = (rule: config.ConfigRule) => ({
380 configRuleName: rule.ConfigRuleName!,
381 configRuleArn: rule.ConfigRuleArn!,
382 configRuleId: rule.ConfigRuleId!,
383 });
384
385 return ConfigRule.Provider.of({
386 stables: ["configRuleName", "configRuleArn", "configRuleId"],
387 list: () =>
388 config.describeConfigRules.items({}).pipe(
389 Stream.runCollect,
390 Effect.map((rules) =>
391 Array.from(rules).flatMap((rule) =>
392 rule.ConfigRuleName && rule.ConfigRuleArn && rule.ConfigRuleId
393 ? [toAttrs(rule)]
394 : [],
395 ),
396 ),
397 ),
398 read: Effect.fn(function* ({ id, olds, output }) {
399 const ruleName =
400 output?.configRuleName ?? (yield* createRuleName(id, olds ?? {}));
401 const rule = yield* observeRule(ruleName);
402 if (rule?.ConfigRuleArn === undefined) return undefined;
403 const attrs = toAttrs(rule);
404 const tags = yield* observedTags(rule.ConfigRuleArn);
405 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
406 }),
407 diff: Effect.fn(function* ({ id, news, olds }) {
408 if (!isResolved(news)) return undefined;
409 const oldName = yield* createRuleName(id, olds ?? {});
410 const newName = yield* createRuleName(id, news);
411 if (oldName !== newName) {
412 return { action: "replace" } as const;
413 }
414 // fall through: engine default update logic for mutable fields
415 }),
416 reconcile: Effect.fn(function* ({ id, news, output, session }) {
417 const ruleName =
418 output?.configRuleName ?? (yield* createRuleName(id, news));
419 const internalTags = yield* createInternalTags(id);
420 const desiredTags = { ...news.tags, ...internalTags };
421 const desired = toWireRule(ruleName, news);
422
423 // 1. OBSERVE — cloud state is authoritative.
424 const observed = yield* observeRule(ruleName);
425
426 // 2+3. ENSURE + SYNC — PutConfigRule is a full upsert, so a single
427 // call covers both the missing-rule and the drifted-rule case.
428 // Skip the API entirely on no-op. A rule stuck in `DELETING`
429 // rejects with the typed ResourceInUseException; bounded retry.
430 if (observed === undefined || !ruleInSync(observed, desired)) {
431 yield* retryWhileRuleInUse(
432 config.putConfigRule({
433 ConfigRule: desired,
434 Tags: createTagsList(desiredTags),
435 }),
436 );
437 }
438
439 // 4. RETURN fresh attributes — re-observe for the ARN and ID
440 // (PutConfigRule returns an empty body).
441 const live = yield* retryWhileNotVisible(
442 observeRule(ruleName).pipe(
443 Effect.flatMap((rule) =>
444 rule?.ConfigRuleArn !== undefined &&
445 rule.ConfigRuleId !== undefined
446 ? Effect.succeed(rule)
447 : Effect.fail(
448 new ConfigRuleNotVisible({
449 message: `Config rule '${ruleName}' was written but is not yet visible to DescribeConfigRules`,
450 }),
451 ),
452 ),
453 ),
454 );
455
456 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
457 // converges (Put-time Tags only apply on first create).
458 const currentTags = yield* observedTags(live.ConfigRuleArn!);
459 const { upsert, removed } = diffTags(currentTags, desiredTags);
460 if (upsert.length > 0) {
461 yield* config.tagResource({
462 ResourceArn: live.ConfigRuleArn!,
463 Tags: upsert,
464 });
465 }
466 if (removed.length > 0) {
467 yield* config.untagResource({
468 ResourceArn: live.ConfigRuleArn!,
469 TagKeys: removed,
470 });
471 }
472
473 yield* session.note(ruleName);
474 return toAttrs(live);
475 }),
476 delete: Effect.fn(function* ({ output }) {
477 // Rule deletion is asynchronous (state DELETING); a concurrent
478 // put/delete surfaces as ResourceInUseException — bounded retry.
479 yield* retryWhileRuleInUse(
480 config.deleteConfigRule({
481 ConfigRuleName: output.configRuleName,
482 }),
483 ).pipe(
484 Effect.catchTag("NoSuchConfigRuleException", () => Effect.void),
485 );
486 }),
487 });
488 }),
489 );