1import * as cloudtrail from "@distilled.cloud/aws/cloudtrail";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12/**
13 * A data-event resource selection inside a basic event selector. Mirrors the
14 * CloudTrail `DataResource` wire shape.
15 */
16export interface TrailDataResource {
17 /** The resource type, e.g. `AWS::S3::Object` or `AWS::Lambda::Function`. */
18 type: string;
19 /** ARNs (or ARN prefixes) of the resources to log data events for. */
20 values?: string[];
21}
22
23/**
24 * A basic event selector controlling which management/data events the trail
25 * logs. Mirrors the CloudTrail `EventSelector` wire shape.
26 */
27export interface TrailEventSelector {
28 /**
29 * Whether to log read-only events, write-only events, or all.
30 * @default "All"
31 */
32 readWriteType?: "ReadOnly" | "WriteOnly" | "All";
33 /**
34 * Whether the selector includes management events.
35 * @default true
36 */
37 includeManagementEvents?: boolean;
38 /** Data-event resources to log (S3 objects, Lambda functions, …). */
39 dataResources?: TrailDataResource[];
40 /**
41 * Management event sources to exclude (`kms.amazonaws.com`,
42 * `rdsdata.amazonaws.com`).
43 */
44 excludeManagementEventSources?: string[];
45}
46
47/**
48 * A field selector inside an advanced event selector. Mirrors the CloudTrail
49 * `AdvancedFieldSelector` wire shape.
50 */
51export interface TrailFieldSelector {
52 /**
53 * The event record field to select on (e.g. `eventCategory`,
54 * `resources.type`).
55 */
56 field: string;
57 /** Exact-match values. */
58 equals?: string[];
59 /** Prefix-match values. */
60 startsWith?: string[];
61 /** Suffix-match values. */
62 endsWith?: string[];
63 /** Exact-mismatch values. */
64 notEquals?: string[];
65 /** Prefix-mismatch values. */
66 notStartsWith?: string[];
67 /** Suffix-mismatch values. */
68 notEndsWith?: string[];
69}
70
71/**
72 * An advanced event selector controlling which events the trail logs.
73 * Advanced and basic event selectors are mutually exclusive.
74 */
75export interface TrailAdvancedEventSelector {
76 /** Descriptive name for the selector. */
77 name?: string;
78 /** Field selectors that events must match to be logged. */
79 fieldSelectors: TrailFieldSelector[];
80}
81
82/**
83 * An Insights selector enabling anomaly detection on the trail's events.
84 */
85export interface TrailInsightSelector {
86 /** The type of Insights to enable. */
87 insightType: "ApiCallRateInsight" | "ApiErrorRateInsight";
88}
89
90export interface TrailProps {
91 /**
92 * Name of the trail. Must be 3-128 characters, contain only letters,
93 * numbers, periods, underscores, and dashes, and start and end with a
94 * letter or number.
95 * @default ${app}-${stage}-${id}
96 */
97 trailName?: string;
98 /**
99 * Name of the Amazon S3 bucket designated for publishing log files.
100 * The bucket policy must grant `cloudtrail.amazonaws.com` the
101 * `s3:GetBucketAcl` and `s3:PutObject` permissions, scoped with an
102 * `aws:SourceArn` condition on the trail's ARN.
103 */
104 s3BucketName: string;
105 /**
106 * Amazon S3 key prefix that follows the name of the bucket designated
107 * for log file delivery.
108 */
109 s3KeyPrefix?: string;
110 /**
111 * Whether the trail publishes events from global services such as IAM
112 * to the log files.
113 * @default true
114 */
115 includeGlobalServiceEvents?: boolean;
116 /**
117 * Whether the trail applies to all Regions or only the Region in which
118 * it was created.
119 * @default false
120 */
121 isMultiRegionTrail?: boolean;
122 /**
123 * Whether log file validation (SHA-256 digest files) is enabled.
124 * @default false
125 */
126 enableLogFileValidation?: boolean;
127 /**
128 * ARN of the CloudWatch Logs log group to which CloudTrail delivers
129 * events. Requires `cloudWatchLogsRoleArn`.
130 */
131 cloudWatchLogsLogGroupArn?: string;
132 /**
133 * Role ARN that CloudTrail assumes to write to the CloudWatch Logs
134 * log group. Requires `cloudWatchLogsLogGroupArn`.
135 */
136 cloudWatchLogsRoleArn?: string;
137 /**
138 * KMS key ID (ID, alias, or ARN) used to encrypt the log files
139 * delivered by CloudTrail. Compared verbatim against the observed
140 * trail's key, so prefer the full key ARN to avoid a perpetual diff.
141 */
142 kmsKeyId?: string;
143 /**
144 * Name of the Amazon SNS topic notified of new log file delivery.
145 */
146 snsTopicName?: string;
147 /**
148 * Whether the trail is created for all accounts in an organization,
149 * or only for the current account.
150 * @default false
151 */
152 isOrganizationTrail?: boolean;
153 /**
154 * Whether the trail is actively logging. Synced via `StartLogging` /
155 * `StopLogging`.
156 * @default true
157 */
158 isLogging?: boolean;
159 /**
160 * Basic event selectors, synced via `PutEventSelectors`. Mutually
161 * exclusive with `advancedEventSelectors`. When omitted, the trail's
162 * selectors are left untouched; pass `[]`-free defaults explicitly to
163 * converge them.
164 */
165 eventSelectors?: TrailEventSelector[];
166 /**
167 * Advanced event selectors, synced via `PutEventSelectors`. Mutually
168 * exclusive with `eventSelectors`. When omitted, the trail's selectors
169 * are left untouched.
170 */
171 advancedEventSelectors?: TrailAdvancedEventSelector[];
172 /**
173 * Insights selectors, synced via `PutInsightSelectors`. Pass `[]` to
174 * disable Insights; when omitted, the trail's Insights configuration is
175 * left untouched.
176 */
177 insightSelectors?: TrailInsightSelector[];
178 /**
179 * Tags to apply to the trail. Merged with internal Alchemy tags.
180 */
181 tags?: Record<string, string>;
182}
183
184export interface Trail extends Resource<
185 "AWS.CloudTrail.Trail",
186 TrailProps,
187 {
188 /** Physical name of the trail. */
189 trailName: string;
190 /** ARN of the trail. */
191 trailArn: string;
192 /** The region in which the trail was created. */
193 homeRegion: string;
194 /** S3 bucket the trail delivers log files to. */
195 s3BucketName: string;
196 /** Whether the trail is currently logging. */
197 isLogging: boolean;
198 },
199 never,
200 Providers
201> {}
202
203/**
204 * An AWS CloudTrail trail that records AWS API activity and delivers log
205 * files to an S3 bucket.
206 *
207 * The destination bucket must carry a bucket policy that allows the
208 * `cloudtrail.amazonaws.com` service principal to call `s3:GetBucketAcl`
209 * on the bucket and `s3:PutObject` under `AWSLogs/{accountId}/*`, both
210 * scoped with an `aws:SourceArn` condition on the trail's ARN.
211 * ### Creating Trails
212 * **Example:** Basic Trail
213 * ```typescript
214 * import * as AWS from "alchemy/AWS";
215 *
216 * const bucket = yield* AWS.S3.Bucket("TrailLogs", {
217 * bucketName: `audit-logs-${accountId}`,
218 * forceDestroy: true,
219 * policy: [
220 * {
221 * Effect: "Allow",
222 * Principal: { Service: "cloudtrail.amazonaws.com" },
223 * Action: ["s3:GetBucketAcl"],
224 * Resource: `arn:aws:s3:::audit-logs-${accountId}`,
225 * Condition: { StringEquals: { "aws:SourceArn": trailArn } },
226 * },
227 * {
228 * Effect: "Allow",
229 * Principal: { Service: "cloudtrail.amazonaws.com" },
230 * Action: ["s3:PutObject"],
231 * Resource: `arn:aws:s3:::audit-logs-${accountId}/AWSLogs/${accountId}/*`,
232 * Condition: {
233 * StringEquals: {
234 * "s3:x-amz-acl": "bucket-owner-full-control",
235 * "aws:SourceArn": trailArn,
236 * },
237 * },
238 * },
239 * ],
240 * });
241 *
242 * const trail = yield* AWS.CloudTrail.Trail("Audit", {
243 * trailName: "audit-trail",
244 * s3BucketName: bucket.bucketName,
245 * });
246 * ```
247 *
248 * **Example:** Multi-Region Trail with Log File Validation
249 * ```typescript
250 * const trail = yield* AWS.CloudTrail.Trail("Audit", {
251 * trailName: "org-audit-trail",
252 * s3BucketName: bucket.bucketName,
253 * isMultiRegionTrail: true,
254 * enableLogFileValidation: true,
255 * });
256 * ```
257 *
258 * ### Controlling Logging
259 * **Example:** Pause logging without deleting the trail
260 * ```typescript
261 * const trail = yield* AWS.CloudTrail.Trail("Audit", {
262 * trailName: "audit-trail",
263 * s3BucketName: bucket.bucketName,
264 * isLogging: false,
265 * });
266 * ```
267 *
268 * ### Selecting Events
269 * **Example:** Advanced Event Selectors and Insights
270 * ```typescript
271 * const trail = yield* AWS.CloudTrail.Trail("Audit", {
272 * trailName: "audit-trail",
273 * s3BucketName: bucket.bucketName,
274 * advancedEventSelectors: [
275 * {
276 * name: "Management events only",
277 * fieldSelectors: [
278 * { field: "eventCategory", equals: ["Management"] },
279 * ],
280 * },
281 * ],
282 * insightSelectors: [{ insightType: "ApiCallRateInsight" }],
283 * });
284 * ```
285 *
286 * @resource
287 */
288export const Trail = Resource<Trail>("AWS.CloudTrail.Trail");
289
290/**
291 * S3 bucket-policy propagation is eventually consistent: `CreateTrail` /
292 * `UpdateTrail` can transiently reject a bucket whose policy was written
293 * moments earlier with `InsufficientS3BucketPolicyException` (or
294 * `S3BucketDoesNotExistException` right after bucket creation).
295 *
296 * Expressed as an explicitly-typed helper: inlining `Effect.retry` here
297 * leaves `Retry.Return`'s conditional type unresolved in the provider's
298 * inferred layer type, which TypeScript's declaration emit widens to an
299 * `unknown` R — poisoning the whole `AWS.providers()` union for every
300 * downstream consumer.
301 */
302const retryThroughBucketPropagation = <A, E extends { _tag: string }, R>(
303 self: Effect.Effect<A, E, R>,
304): Effect.Effect<A, E, R> =>
305 Effect.retry(self, {
306 while: (e) =>
307 e._tag === "InsufficientS3BucketPolicyException" ||
308 e._tag === "S3BucketDoesNotExistException" ||
309 e._tag === "ConflictException",
310 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
311 });
312
313/**
314 * Trail deletion right after a Start/StopLogging call (or another
315 * concurrent mutation) can raise a transient `ConflictException`.
316 */
317const retryWhileConflict = <A, E extends { _tag: string }, R>(
318 self: Effect.Effect<A, E, R>,
319): Effect.Effect<A, E, R> =>
320 Effect.retry(self, {
321 while: (e) => e._tag === "ConflictException",
322 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
323 });
324
325const toWireEventSelectors = (
326 selectors: TrailEventSelector[],
327): cloudtrail.EventSelector[] =>
328 selectors.map((s) => ({
329 ReadWriteType: s.readWriteType,
330 IncludeManagementEvents: s.includeManagementEvents,
331 DataResources: s.dataResources?.map((r) => ({
332 Type: r.type,
333 Values: r.values,
334 })),
335 ExcludeManagementEventSources: s.excludeManagementEventSources,
336 }));
337
338const toWireAdvancedSelectors = (
339 selectors: TrailAdvancedEventSelector[],
340): cloudtrail.AdvancedEventSelector[] =>
341 selectors.map((s) => ({
342 Name: s.name,
343 FieldSelectors: s.fieldSelectors.map((f) => ({
344 Field: f.field,
345 Equals: f.equals,
346 StartsWith: f.startsWith,
347 EndsWith: f.endsWith,
348 NotEquals: f.notEquals,
349 NotStartsWith: f.notStartsWith,
350 NotEndsWith: f.notEndsWith,
351 })),
352 }));
353
354export const TrailProvider = () =>
355 Provider.effect(
356 Trail,
357 Effect.gen(function* () {
358 const createTrailName = Effect.fn(function* (
359 id: string,
360 props: { trailName?: string | undefined },
361 ) {
362 if (props.trailName) {
363 return props.trailName;
364 }
365 return yield* createPhysicalName({ id, maxLength: 128 });
366 });
367
368 const fetchObservedTags = (trailArn: string) =>
369 cloudtrail.listTags({ ResourceIdList: [trailArn] }).pipe(
370 Effect.map((r) => {
371 const tags: Record<string, string> = {};
372 for (const t of r.ResourceTagList?.[0]?.TagsList ?? []) {
373 tags[t.Key] = t.Value ?? "";
374 }
375 return tags;
376 }),
377 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
378 );
379
380 const readTrail = (name: string) =>
381 cloudtrail.getTrail({ Name: name }).pipe(
382 Effect.map((r) => r.Trail),
383 Effect.catchTag("TrailNotFoundException", () =>
384 Effect.succeed(undefined),
385 ),
386 );
387
388 const toAttributes = Effect.fn(function* (trail: cloudtrail.Trail) {
389 const name = trail.Name!;
390 const isLogging = yield* cloudtrail
391 .getTrailStatus({ Name: trail.TrailARN ?? name })
392 .pipe(
393 Effect.map((s) => s.IsLogging ?? false),
394 Effect.catchTag("TrailNotFoundException", () =>
395 Effect.succeed(false),
396 ),
397 );
398 return {
399 trailName: name,
400 trailArn: trail.TrailARN!,
401 homeRegion: trail.HomeRegion!,
402 s3BucketName: trail.S3BucketName!,
403 isLogging,
404 };
405 });
406
407 return Trail.Provider.of({
408 stables: ["trailName", "trailArn", "homeRegion"],
409 list: () =>
410 Effect.gen(function* () {
411 const trails = yield* cloudtrail
412 .describeTrails({ includeShadowTrails: false })
413 .pipe(Effect.map((r) => r.trailList ?? []));
414 return yield* Effect.forEach(
415 trails.filter(
416 (t) => t.Name !== undefined && t.TrailARN !== undefined,
417 ),
418 (t) => toAttributes(t),
419 { concurrency: 5 },
420 );
421 }),
422 read: Effect.fn(function* ({ id, olds, output }) {
423 const name =
424 output?.trailName ?? (yield* createTrailName(id, olds ?? {}));
425 const trail = yield* readTrail(name);
426 if (trail === undefined) return undefined;
427 const attrs = yield* toAttributes(trail);
428 const tags = yield* fetchObservedTags(attrs.trailArn);
429 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
430 }),
431 diff: Effect.fn(function* ({ id, news, olds }) {
432 if (!isResolved(news)) return undefined;
433 const oldName = yield* createTrailName(id, olds ?? {});
434 const newName = yield* createTrailName(id, news);
435 if (oldName !== newName) {
436 return { action: "replace" } as const;
437 }
438 // Everything else is mutable via UpdateTrail / Start/StopLogging.
439 }),
440 reconcile: Effect.fn(function* ({ id, news, output, session }) {
441 const name = output?.trailName ?? (yield* createTrailName(id, news));
442 const internalTags = yield* createInternalTags(id);
443
444 // Desired settings, with AWS defaults made explicit so removing a
445 // prop converges back to the default.
446 const desired = {
447 S3BucketName: news.s3BucketName,
448 S3KeyPrefix: news.s3KeyPrefix,
449 IncludeGlobalServiceEvents: news.includeGlobalServiceEvents ?? true,
450 IsMultiRegionTrail: news.isMultiRegionTrail ?? false,
451 EnableLogFileValidation: news.enableLogFileValidation ?? false,
452 CloudWatchLogsLogGroupArn: news.cloudWatchLogsLogGroupArn,
453 CloudWatchLogsRoleArn: news.cloudWatchLogsRoleArn,
454 KmsKeyId: news.kmsKeyId,
455 SnsTopicName: news.snsTopicName,
456 IsOrganizationTrail: news.isOrganizationTrail ?? false,
457 };
458
459 // 1. OBSERVE — cloud state is authoritative.
460 let trail = yield* readTrail(name);
461
462 // 2. ENSURE — create if missing; tolerate the AlreadyExists race.
463 if (trail === undefined) {
464 yield* session.note(`Creating CloudTrail trail ${name}`);
465 yield* retryThroughBucketPropagation(
466 cloudtrail.createTrail({
467 Name: name,
468 ...desired,
469 TagsList: Object.entries({
470 ...news.tags,
471 ...internalTags,
472 }).map(([Key, Value]) => ({ Key, Value })),
473 }),
474 ).pipe(
475 Effect.catchTag("TrailAlreadyExistsException", () => Effect.void),
476 );
477 trail = yield* readTrail(name);
478 }
479 if (trail === undefined) {
480 // The trail vanished between create and re-read — surface the
481 // typed not-found from a direct get instead of inventing one.
482 trail = (yield* cloudtrail.getTrail({ Name: name })).Trail!;
483 }
484
485 // 3. SYNC settings — diff observed against desired, push only the
486 // fields that actually changed (UpdateTrail leaves omitted fields
487 // untouched, except booleans which we always pin to desired).
488 const settingsDelta: Partial<
489 Omit<cloudtrail.UpdateTrailRequest, "Name">
490 > = {};
491 if (trail.S3BucketName !== desired.S3BucketName) {
492 settingsDelta.S3BucketName = desired.S3BucketName;
493 }
494 if ((trail.S3KeyPrefix ?? "") !== (desired.S3KeyPrefix ?? "")) {
495 settingsDelta.S3KeyPrefix = desired.S3KeyPrefix ?? "";
496 }
497 if (
498 (trail.IncludeGlobalServiceEvents ?? true) !==
499 desired.IncludeGlobalServiceEvents
500 ) {
501 settingsDelta.IncludeGlobalServiceEvents =
502 desired.IncludeGlobalServiceEvents;
503 }
504 if (
505 (trail.IsMultiRegionTrail ?? false) !== desired.IsMultiRegionTrail
506 ) {
507 settingsDelta.IsMultiRegionTrail = desired.IsMultiRegionTrail;
508 }
509 if (
510 (trail.LogFileValidationEnabled ?? false) !==
511 desired.EnableLogFileValidation
512 ) {
513 settingsDelta.EnableLogFileValidation =
514 desired.EnableLogFileValidation;
515 }
516 if (
517 desired.CloudWatchLogsLogGroupArn !== undefined &&
518 trail.CloudWatchLogsLogGroupArn !==
519 desired.CloudWatchLogsLogGroupArn
520 ) {
521 settingsDelta.CloudWatchLogsLogGroupArn =
522 desired.CloudWatchLogsLogGroupArn;
523 settingsDelta.CloudWatchLogsRoleArn = desired.CloudWatchLogsRoleArn;
524 }
525 if (
526 desired.KmsKeyId !== undefined &&
527 trail.KmsKeyId !== desired.KmsKeyId
528 ) {
529 settingsDelta.KmsKeyId = desired.KmsKeyId;
530 }
531 if (
532 (trail.IsOrganizationTrail ?? false) !== desired.IsOrganizationTrail
533 ) {
534 settingsDelta.IsOrganizationTrail = desired.IsOrganizationTrail;
535 }
536 if (
537 desired.SnsTopicName !== undefined &&
538 trail.SnsTopicName !== desired.SnsTopicName
539 ) {
540 settingsDelta.SnsTopicName = desired.SnsTopicName;
541 }
542 if (Object.keys(settingsDelta).length > 0) {
543 yield* session.note(
544 `Updating trail settings: ${Object.keys(settingsDelta).join(", ")}`,
545 );
546 yield* retryThroughBucketPropagation(
547 cloudtrail.updateTrail({ Name: name, ...settingsDelta }),
548 );
549 trail = (yield* cloudtrail.getTrail({ Name: name })).Trail ?? trail;
550 }
551
552 const trailArn = trail.TrailARN!;
553
554 // 3b. SYNC logging state via Start/StopLogging.
555 const desiredLogging = news.isLogging ?? true;
556 const observedLogging = yield* cloudtrail
557 .getTrailStatus({ Name: trailArn })
558 .pipe(Effect.map((s) => s.IsLogging ?? false));
559 if (observedLogging !== desiredLogging) {
560 if (desiredLogging) {
561 yield* session.note(`Starting logging for trail ${name}`);
562 yield* retryWhileConflict(
563 cloudtrail.startLogging({ Name: trailArn }),
564 );
565 } else {
566 yield* session.note(`Stopping logging for trail ${name}`);
567 yield* retryWhileConflict(
568 cloudtrail.stopLogging({ Name: trailArn }),
569 );
570 }
571 }
572
573 // 3c. SYNC event selectors — only when declared (undefined leaves
574 // the trail's selectors untouched). Basic and advanced selectors
575 // are mutually exclusive in the API; observed cloud selectors are
576 // the diff baseline.
577 if (
578 news.eventSelectors !== undefined ||
579 news.advancedEventSelectors !== undefined
580 ) {
581 const observedSelectors = yield* cloudtrail.getEventSelectors({
582 TrailName: trailArn,
583 });
584 if (news.advancedEventSelectors !== undefined) {
585 const desiredAdvanced = toWireAdvancedSelectors(
586 news.advancedEventSelectors,
587 );
588 if (
589 JSON.stringify(
590 observedSelectors.AdvancedEventSelectors ?? [],
591 ) !== JSON.stringify(desiredAdvanced)
592 ) {
593 yield* session.note(`Updating advanced event selectors`);
594 yield* retryWhileConflict(
595 cloudtrail.putEventSelectors({
596 TrailName: trailArn,
597 AdvancedEventSelectors: desiredAdvanced,
598 }),
599 );
600 }
601 } else {
602 const desiredBasic = toWireEventSelectors(news.eventSelectors!);
603 if (
604 JSON.stringify(observedSelectors.EventSelectors ?? []) !==
605 JSON.stringify(desiredBasic)
606 ) {
607 yield* session.note(`Updating event selectors`);
608 yield* retryWhileConflict(
609 cloudtrail.putEventSelectors({
610 TrailName: trailArn,
611 EventSelectors: desiredBasic,
612 }),
613 );
614 }
615 }
616 }
617
618 // 3d. SYNC Insights selectors — only when declared. `[]` disables
619 // Insights; a trail with Insights disabled reads as [] (the typed
620 // InsightNotEnabledException).
621 if (news.insightSelectors !== undefined) {
622 const observedInsights = yield* cloudtrail
623 .getInsightSelectors({ TrailName: trailArn })
624 .pipe(
625 Effect.map((r) => r.InsightSelectors ?? []),
626 Effect.catchTag("InsightNotEnabledException", () =>
627 Effect.succeed([] as cloudtrail.InsightSelector[]),
628 ),
629 );
630 const desiredInsights = news.insightSelectors.map((s) => ({
631 InsightType: s.insightType,
632 }));
633 if (
634 JSON.stringify(
635 observedInsights.map((s) => s.InsightType).sort(),
636 ) !==
637 JSON.stringify(desiredInsights.map((s) => s.InsightType).sort())
638 ) {
639 yield* session.note(`Updating Insights selectors`);
640 yield* retryWhileConflict(
641 cloudtrail.putInsightSelectors({
642 TrailName: trailArn,
643 InsightSelectors: desiredInsights,
644 }),
645 );
646 }
647 }
648
649 // 3e. SYNC tags against OBSERVED cloud tags (adoption-safe).
650 const observedTags = yield* fetchObservedTags(trailArn);
651 const { upsert, removed } = diffTags(observedTags, {
652 ...news.tags,
653 ...internalTags,
654 });
655 if (upsert.length > 0) {
656 yield* cloudtrail.addTags({
657 ResourceId: trailArn,
658 TagsList: upsert.map((t) => ({ Key: t.Key, Value: t.Value })),
659 });
660 }
661 if (removed.length > 0) {
662 yield* cloudtrail.removeTags({
663 ResourceId: trailArn,
664 TagsList: removed.map((Key) => ({ Key })),
665 });
666 }
667
668 yield* session.note(trailArn);
669 return {
670 trailName: name,
671 trailArn,
672 homeRegion: trail.HomeRegion!,
673 s3BucketName: trail.S3BucketName!,
674 isLogging: desiredLogging,
675 };
676 }),
677 delete: Effect.fn(function* ({ output }) {
678 yield* retryWhileConflict(
679 cloudtrail.deleteTrail({ Name: output.trailArn }),
680 ).pipe(
681 Effect.catchTag("TrailNotFoundException", () => Effect.void),
682 // A stale ARN whose trail is already gone can also surface as an
683 // invalid-ARN complaint; both mean "nothing left to delete".
684 Effect.catchTag("CloudTrailARNInvalidException", () => Effect.void),
685 );
686 }),
687 });
688 }),
689 );