1import * as cloudtrail from "@distilled.cloud/aws/cloudtrail";
2import type * as Duration from "effect/Duration";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { toWireDays } from "../../Util/Duration.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * A field selector inside an advanced event selector. Mirrors the
17 * CloudTrail `AdvancedFieldSelector` wire shape.
18 */
19export interface EventDataStoreFieldSelector {
20 /**
21 * The event record field to select on (e.g. `eventCategory`,
22 * `resources.type`).
23 */
24 field: string;
25 /** Exact-match values. */
26 equals?: string[];
27 /** Prefix-match values. */
28 startsWith?: string[];
29 /** Suffix-match values. */
30 endsWith?: string[];
31 /** Exact-mismatch values. */
32 notEquals?: string[];
33 /** Prefix-mismatch values. */
34 notStartsWith?: string[];
35 /** Suffix-mismatch values. */
36 notEndsWith?: string[];
37}
38
39/**
40 * An advanced event selector controlling which events the event data
41 * store collects.
42 */
43export interface EventDataStoreEventSelector {
44 /** Descriptive name for the selector. */
45 name?: string;
46 /** Field selectors that events must match to be collected. */
47 fieldSelectors: EventDataStoreFieldSelector[];
48}
49
50export interface EventDataStoreProps {
51 /**
52 * Name of the event data store. Must be 3-128 characters, contain only
53 * letters, numbers, periods, underscores, and dashes, and start and end
54 * with a letter or number. Renaming updates the store in place (the ARN
55 * is the stable identity).
56 * @default ${app}-${stage}-${id}
57 */
58 name?: string;
59 /**
60 * Advanced event selectors controlling which events are collected.
61 * @default management events
62 */
63 advancedEventSelectors?: EventDataStoreEventSelector[];
64 /**
65 * Whether the event data store collects events from all Regions or
66 * only the current Region.
67 * @default true
68 */
69 multiRegionEnabled?: boolean;
70 /**
71 * Whether the event data store collects events for all accounts in an
72 * organization.
73 * @default false
74 */
75 organizationEnabled?: boolean;
76 /**
77 * Retention period, e.g. `"30 days"` or `Duration.days(30)` (minimum
78 * 7 days; maximum 2557 days under `EXTENDABLE_RETENTION_PRICING`,
79 * 3653 days under `FIXED_RETENTION_PRICING`). Rounded to whole days
80 * on the wire.
81 * @default 366 days
82 */
83 retentionPeriod?: Duration.Input;
84 /**
85 * Whether termination protection is enabled. A protected store cannot
86 * be deleted until protection is disabled.
87 * @default true (AWS default)
88 */
89 terminationProtectionEnabled?: boolean;
90 /**
91 * The billing mode for the event data store.
92 * @default "EXTENDABLE_RETENTION_PRICING"
93 */
94 billingMode?: "EXTENDABLE_RETENTION_PRICING" | "FIXED_RETENTION_PRICING";
95 /**
96 * KMS key ID (ID, alias, or ARN) used to encrypt the events delivered
97 * to the event data store.
98 */
99 kmsKeyId?: string;
100 /**
101 * Whether the event data store ingests live events. Synced via
102 * `StartEventDataStoreIngestion` / `StopEventDataStoreIngestion`; a
103 * stopped store keeps its collected events queryable.
104 * @default true (AWS default)
105 */
106 ingestionEnabled?: boolean;
107 /**
108 * Tags to apply to the event data store. Merged with internal Alchemy
109 * tags.
110 */
111 tags?: Record<string, string>;
112}
113
114export interface EventDataStore extends Resource<
115 "AWS.CloudTrail.EventDataStore",
116 EventDataStoreProps,
117 {
118 /** ARN of the event data store (its stable identity). */
119 eventDataStoreArn: string;
120 /** Name of the event data store. */
121 name: string;
122 /** Current status (e.g. `ENABLED`, `STARTING_INGESTION`). */
123 status: string;
124 },
125 never,
126 Providers
127> {}
128
129/**
130 * A CloudTrail Lake event data store — an immutable collection of events
131 * that can be queried with SQL via CloudTrail Lake.
132 *
133 * Deleting an event data store schedules it for deletion
134 * (`PENDING_DELETION`); AWS purges it after a seven-day wait period during
135 * which it incurs no cost. If a store with the same name is still pending
136 * deletion, the reconciler restores it instead of creating a duplicate.
137 * ### Creating Event Data Stores
138 * **Example:** Basic Event Data Store
139 * ```typescript
140 * import * as AWS from "alchemy/AWS";
141 *
142 * const store = yield* AWS.CloudTrail.EventDataStore("Lake", {
143 * retentionPeriod: "7 days",
144 * terminationProtectionEnabled: false,
145 * });
146 * ```
147 *
148 * **Example:** Single-Region Store with Custom Selectors
149 * ```typescript
150 * const store = yield* AWS.CloudTrail.EventDataStore("S3DataEvents", {
151 * multiRegionEnabled: false,
152 * retentionPeriod: "30 days",
153 * terminationProtectionEnabled: false,
154 * advancedEventSelectors: [
155 * {
156 * name: "S3 data events",
157 * fieldSelectors: [
158 * { field: "eventCategory", equals: ["Data"] },
159 * { field: "resources.type", equals: ["AWS::S3::Object"] },
160 * ],
161 * },
162 * ],
163 * });
164 * ```
165 *
166 * @resource
167 */
168export const EventDataStore = Resource<EventDataStore>(
169 "AWS.CloudTrail.EventDataStore",
170);
171
172/**
173 * A just-created or just-restored event data store transitions through
174 * `CREATED`/`STARTING_INGESTION` before mutations are accepted; concurrent
175 * mutations surface as transient `ConflictException` /
176 * `InvalidEventDataStoreStatusException`.
177 *
178 * Expressed as an explicitly-typed helper: inlining `Effect.retry` in a
179 * lifecycle op leaks `Retry.Return`'s conditional into declaration emit
180 * and widens the provider layer to an `unknown` R for every consumer of
181 * `AWS.providers()`.
182 */
183const retryWhileSettling = <A, E extends { _tag: string }, R>(
184 self: Effect.Effect<A, E, R>,
185): Effect.Effect<A, E, R> =>
186 Effect.retry(self, {
187 while: (e) =>
188 e._tag === "ConflictException" ||
189 e._tag === "InvalidEventDataStoreStatusException" ||
190 e._tag === "InactiveEventDataStoreException",
191 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
192 });
193
194/**
195 * Deletion right after another mutation can raise a transient
196 * `ConflictException`; already-inactive stores are handled by the caller
197 * as success and must NOT be retried.
198 */
199const retryWhileDeleteConflict = <A, E extends { _tag: string }, R>(
200 self: Effect.Effect<A, E, R>,
201): Effect.Effect<A, E, R> =>
202 Effect.retry(self, {
203 while: (e) => e._tag === "ConflictException",
204 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
205 });
206
207const toWireSelectors = (
208 selectors: EventDataStoreEventSelector[] | undefined,
209): cloudtrail.AdvancedEventSelector[] | undefined =>
210 selectors?.map((s) => ({
211 Name: s.name,
212 FieldSelectors: s.fieldSelectors.map((f) => ({
213 Field: f.field,
214 Equals: f.equals,
215 StartsWith: f.startsWith,
216 EndsWith: f.endsWith,
217 NotEquals: f.notEquals,
218 NotStartsWith: f.notStartsWith,
219 NotEndsWith: f.notEndsWith,
220 })),
221 }));
222
223export const EventDataStoreProvider = () =>
224 Provider.effect(
225 EventDataStore,
226 Effect.gen(function* () {
227 const createName = Effect.fn(function* (
228 id: string,
229 props: { name?: string | undefined },
230 ) {
231 if (props.name) {
232 return props.name;
233 }
234 return yield* createPhysicalName({ id, maxLength: 128 });
235 });
236
237 const fetchObservedTags = (arn: string) =>
238 cloudtrail.listTags({ ResourceIdList: [arn] }).pipe(
239 Effect.map((r) => {
240 const tags: Record<string, string> = {};
241 for (const t of r.ResourceTagList?.[0]?.TagsList ?? []) {
242 tags[t.Key] = t.Value ?? "";
243 }
244 return tags;
245 }),
246 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
247 );
248
249 // Look up a store by ARN, tolerating the typed not-found.
250 const getByArn = (arn: string) =>
251 cloudtrail
252 .getEventDataStore({ EventDataStore: arn })
253 .pipe(
254 Effect.catchTag(
255 [
256 "EventDataStoreNotFoundException",
257 "EventDataStoreARNInvalidException",
258 ],
259 () => Effect.succeed(undefined),
260 ),
261 );
262
263 // Find a store by name, INCLUDING pending-deletion ones — the caller
264 // decides whether a `PENDING_DELETION` match should be restored.
265 const findByName = (name: string) =>
266 cloudtrail.listEventDataStores.pages({}).pipe(
267 Stream.runCollect,
268 Effect.map((chunk) =>
269 Array.from(chunk)
270 .flatMap((page) => page.EventDataStores ?? [])
271 .find((s) => s.Name === name),
272 ),
273 );
274
275 return EventDataStore.Provider.of({
276 stables: ["eventDataStoreArn", "name"],
277 list: () =>
278 Effect.gen(function* () {
279 const stores = yield* cloudtrail.listEventDataStores.pages({}).pipe(
280 Stream.runCollect,
281 Effect.map((chunk) =>
282 Array.from(chunk).flatMap((page) => page.EventDataStores ?? []),
283 ),
284 );
285 return stores
286 .filter(
287 (s) =>
288 s.EventDataStoreArn !== undefined &&
289 s.Name !== undefined &&
290 s.Status !== "PENDING_DELETION",
291 )
292 .map((s) => ({
293 eventDataStoreArn: s.EventDataStoreArn!,
294 name: s.Name!,
295 status: s.Status ?? "ENABLED",
296 }));
297 }),
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const store = output?.eventDataStoreArn
300 ? yield* getByArn(output.eventDataStoreArn)
301 : yield* findByName(yield* createName(id, olds ?? {}));
302 if (store === undefined || store.Status === "PENDING_DELETION") {
303 return undefined;
304 }
305 const attrs = {
306 eventDataStoreArn: store.EventDataStoreArn!,
307 name: store.Name!,
308 status: store.Status ?? "ENABLED",
309 };
310 const tags = yield* fetchObservedTags(attrs.eventDataStoreArn);
311 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
312 }),
313 diff: Effect.fn(function* ({ news }) {
314 if (!isResolved(news)) return undefined;
315 // Every prop (including the name) is mutable via
316 // UpdateEventDataStore — the ARN is the stable identity, so no
317 // prop change forces a replacement.
318 }),
319 reconcile: Effect.fn(function* ({ id, news, output, session }) {
320 const name = yield* createName(id, news);
321 const internalTags = yield* createInternalTags(id);
322 const desiredSelectors = toWireSelectors(news.advancedEventSelectors);
323 // The CloudTrail wire unit for RetentionPeriod is whole days.
324 const retentionDays = toWireDays(news.retentionPeriod);
325
326 // 1. OBSERVE — prefer the cached ARN, fall back to name lookup.
327 let store = output?.eventDataStoreArn
328 ? yield* getByArn(output.eventDataStoreArn)
329 : yield* findByName(name).pipe(
330 Effect.flatMap((s) =>
331 s?.EventDataStoreArn
332 ? getByArn(s.EventDataStoreArn)
333 : Effect.succeed(undefined),
334 ),
335 );
336
337 // A pending-deletion store still holds the name — restore it and
338 // let the sync step converge it to the desired state.
339 if (store !== undefined && store.Status === "PENDING_DELETION") {
340 yield* session.note(
341 `Restoring pending-deletion event data store ${store.Name}`,
342 );
343 yield* retryWhileSettling(
344 cloudtrail.restoreEventDataStore({
345 EventDataStore: store.EventDataStoreArn!,
346 }),
347 );
348 store = yield* getByArn(store.EventDataStoreArn!);
349 }
350
351 // 2. ENSURE — create if missing; on the AlreadyExists race (or a
352 // same-name store pending deletion), converge via name lookup +
353 // restore.
354 if (store === undefined) {
355 yield* session.note(`Creating event data store ${name}`);
356 const created = yield* cloudtrail
357 .createEventDataStore({
358 Name: name,
359 AdvancedEventSelectors: desiredSelectors,
360 MultiRegionEnabled: news.multiRegionEnabled,
361 OrganizationEnabled: news.organizationEnabled,
362 RetentionPeriod: retentionDays,
363 TerminationProtectionEnabled: news.terminationProtectionEnabled,
364 BillingMode: news.billingMode,
365 KmsKeyId: news.kmsKeyId,
366 StartIngestion: news.ingestionEnabled,
367 TagsList: Object.entries({
368 ...news.tags,
369 ...internalTags,
370 }).map(([Key, Value]) => ({ Key, Value })),
371 })
372 .pipe(
373 Effect.catchTag("EventDataStoreAlreadyExistsException", () =>
374 Effect.succeed(undefined),
375 ),
376 );
377 if (created?.EventDataStoreArn) {
378 store = yield* getByArn(created.EventDataStoreArn);
379 } else {
380 // AlreadyExists race — the holder may be active (peer created
381 // it) or pending deletion (a prior stack run deleted it).
382 const existing = yield* findByName(name);
383 if (existing?.Status === "PENDING_DELETION") {
384 yield* session.note(
385 `Name ${name} is held by a pending-deletion store — restoring it`,
386 );
387 yield* retryWhileSettling(
388 cloudtrail.restoreEventDataStore({
389 EventDataStore: existing.EventDataStoreArn!,
390 }),
391 );
392 }
393 store = existing?.EventDataStoreArn
394 ? yield* getByArn(existing.EventDataStoreArn)
395 : undefined;
396 }
397 }
398 if (store === undefined) {
399 // Surface the typed not-found from a direct name-keyed get.
400 store = yield* cloudtrail.getEventDataStore({
401 EventDataStore: name,
402 });
403 }
404
405 const arn = store.EventDataStoreArn!;
406
407 // 3. SYNC — diff observed against desired, push only the delta.
408 const updateDelta: Partial<
409 Omit<cloudtrail.UpdateEventDataStoreRequest, "EventDataStore">
410 > = {};
411 if (store.Name !== name) {
412 updateDelta.Name = name;
413 }
414 if (
415 retentionDays !== undefined &&
416 store.RetentionPeriod !== retentionDays
417 ) {
418 updateDelta.RetentionPeriod = retentionDays;
419 }
420 if (
421 news.multiRegionEnabled !== undefined &&
422 store.MultiRegionEnabled !== news.multiRegionEnabled
423 ) {
424 updateDelta.MultiRegionEnabled = news.multiRegionEnabled;
425 }
426 if (
427 news.organizationEnabled !== undefined &&
428 store.OrganizationEnabled !== news.organizationEnabled
429 ) {
430 updateDelta.OrganizationEnabled = news.organizationEnabled;
431 }
432 if (
433 news.terminationProtectionEnabled !== undefined &&
434 store.TerminationProtectionEnabled !==
435 news.terminationProtectionEnabled
436 ) {
437 updateDelta.TerminationProtectionEnabled =
438 news.terminationProtectionEnabled;
439 }
440 if (
441 news.billingMode !== undefined &&
442 store.BillingMode !== news.billingMode
443 ) {
444 updateDelta.BillingMode = news.billingMode;
445 }
446 if (news.kmsKeyId !== undefined && store.KmsKeyId !== news.kmsKeyId) {
447 updateDelta.KmsKeyId = news.kmsKeyId;
448 }
449 if (
450 desiredSelectors !== undefined &&
451 JSON.stringify(store.AdvancedEventSelectors ?? []) !==
452 JSON.stringify(desiredSelectors)
453 ) {
454 updateDelta.AdvancedEventSelectors = desiredSelectors;
455 }
456 if (Object.keys(updateDelta).length > 0) {
457 yield* session.note(
458 `Updating event data store: ${Object.keys(updateDelta).join(", ")}`,
459 );
460 store = yield* retryWhileSettling(
461 cloudtrail.updateEventDataStore({
462 EventDataStore: arn,
463 ...updateDelta,
464 }),
465 );
466 }
467
468 // 3b. SYNC ingestion state — only when declared. The store's
469 // Status is the observed ingestion state: ENABLED ingests,
470 // STOPPED_INGESTION does not. retryWhileSettling rides out the
471 // CREATED/STARTING_INGESTION window after create/restore.
472 if (news.ingestionEnabled !== undefined) {
473 const status = store.Status ?? "ENABLED";
474 if (news.ingestionEnabled && status === "STOPPED_INGESTION") {
475 yield* session.note(`Starting ingestion for ${name}`);
476 yield* retryWhileSettling(
477 cloudtrail.startEventDataStoreIngestion({
478 EventDataStore: arn,
479 }),
480 );
481 store = (yield* getByArn(arn)) ?? store;
482 } else if (
483 !news.ingestionEnabled &&
484 status !== "STOPPED_INGESTION"
485 ) {
486 yield* session.note(`Stopping ingestion for ${name}`);
487 yield* retryWhileSettling(
488 cloudtrail.stopEventDataStoreIngestion({
489 EventDataStore: arn,
490 }),
491 );
492 store = (yield* getByArn(arn)) ?? store;
493 }
494 }
495
496 // 3c. SYNC tags against OBSERVED cloud tags (adoption-safe).
497 const observedTags = yield* fetchObservedTags(arn);
498 const { upsert, removed } = diffTags(observedTags, {
499 ...news.tags,
500 ...internalTags,
501 });
502 if (upsert.length > 0) {
503 yield* cloudtrail.addTags({
504 ResourceId: arn,
505 TagsList: upsert.map((t) => ({ Key: t.Key, Value: t.Value })),
506 });
507 }
508 if (removed.length > 0) {
509 yield* cloudtrail.removeTags({
510 ResourceId: arn,
511 TagsList: removed.map((Key) => ({ Key })),
512 });
513 }
514
515 yield* session.note(arn);
516 return {
517 eventDataStoreArn: arn,
518 name: store.Name ?? name,
519 status: store.Status ?? "ENABLED",
520 };
521 }),
522 // DeleteEventDataStore schedules PENDING_DELETION (7-day window, no
523 // cost). Idempotent: already-deleted (NotFound/ARNInvalid) and
524 // already-pending (Inactive) stores are both success. A protected
525 // store fails with the typed
526 // `EventDataStoreTerminationProtectedException` — disable
527 // `terminationProtectionEnabled` first.
528 delete: Effect.fn(function* ({ output }) {
529 yield* retryWhileDeleteConflict(
530 cloudtrail.deleteEventDataStore({
531 EventDataStore: output.eventDataStoreArn,
532 }),
533 ).pipe(
534 Effect.catchTag(
535 [
536 "EventDataStoreNotFoundException",
537 "EventDataStoreARNInvalidException",
538 "InactiveEventDataStoreException",
539 ],
540 () => Effect.void,
541 ),
542 );
543 }),
544 });
545 }),
546 );