1import * as aiops from "@distilled.cloud/aws/aiops";
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 { PolicyStatement } from "../IAM/Policy.ts";
14import type { Providers } from "../Providers.ts";
15
16export interface InvestigationGroupEncryptionConfiguration {
17 /**
18 * How investigation data is encrypted — `"AWS_OWNED_KEY"` (the default,
19 * an AWS owned key) or `"CUSTOMER_MANAGED_KMS_KEY"`.
20 * @default "AWS_OWNED_KEY"
21 */
22 type?: aiops.EncryptionConfigurationType;
23 /**
24 * ID or ARN of the customer managed KMS key to encrypt investigation data
25 * with. Required when `type` is `"CUSTOMER_MANAGED_KMS_KEY"`.
26 */
27 kmsKeyId?: string;
28}
29
30export interface InvestigationGroupCrossAccountConfiguration {
31 /**
32 * ARN of an IAM role in a source account that CloudWatch investigations
33 * assumes to retrieve telemetry during cross-account investigations.
34 */
35 sourceRoleArn: string;
36}
37
38export interface InvestigationGroupProps {
39 /**
40 * Name of the investigation group. If omitted, a deterministic physical
41 * name is generated from the app, stage, and logical ID.
42 *
43 * Changing the name replaces the investigation group.
44 * @default ${app}-${stage}-${id}
45 */
46 name?: string;
47 /**
48 * ARN of the IAM role that CloudWatch investigations assumes to access
49 * telemetry (CloudWatch, X-Ray, CloudTrail, ...) during investigations.
50 * The role's trust policy must allow the `aiops.amazonaws.com` service
51 * principal to assume it.
52 */
53 roleArn: string;
54 /**
55 * How long investigations and their data are retained (e.g. `"7 days"`
56 * or `Duration.days(7)`). Rounded to whole days on the wire
57 * (`retentionInDays`).
58 * The retention period cannot be updated in place — changing it replaces
59 * the investigation group.
60 * @default 90 days
61 */
62 retention?: Duration.Input;
63 /**
64 * Encryption configuration for investigation data. Omit to use an AWS
65 * owned key.
66 * @default AWS owned key
67 */
68 encryptionConfiguration?: InvestigationGroupEncryptionConfiguration;
69 /**
70 * Tag keys whose values CloudWatch investigations uses as boundaries to
71 * narrow the search for related telemetry (useful when resource names
72 * repeat across applications).
73 */
74 tagKeyBoundaries?: string[];
75 /**
76 * Map of Amazon Q Developer in chat applications configuration ARNs to
77 * SNS topic ARNs, used to send investigation updates to chat channels.
78 */
79 chatbotNotificationChannel?: Record<string, string[]>;
80 /**
81 * Whether investigations can query CloudTrail event history for the past
82 * seven days of events.
83 * @default true
84 */
85 isCloudTrailEventHistoryEnabled?: boolean;
86 /**
87 * Source-account role configurations for cross-account investigations.
88 */
89 crossAccountConfigurations?: InvestigationGroupCrossAccountConfiguration[];
90 /**
91 * IAM resource-policy statements attached to the investigation group,
92 * granting other principals or AWS services (for example
93 * `aiops.alarms.cloudwatch.amazonaws.com`, so CloudWatch alarms can start
94 * investigations) access to it. Serialized as a `2012-10-17` policy
95 * document via `PutInvestigationGroupPolicy`.
96 *
97 * Omit to leave any existing policy unmanaged; pass `[]` to delete a
98 * previously attached policy.
99 */
100 policy?: PolicyStatement[];
101 /**
102 * Tags to apply to the investigation group. Merged with internal Alchemy
103 * tags.
104 */
105 tags?: Record<string, string>;
106}
107
108export interface InvestigationGroup extends Resource<
109 "AWS.AIOps.InvestigationGroup",
110 InvestigationGroupProps,
111 {
112 /** Name of the investigation group. */
113 name: string;
114 /** ARN of the investigation group. */
115 arn: string;
116 /** ARN of the IAM role investigations assume to access telemetry. */
117 roleArn: string | undefined;
118 /**
119 * How long investigations and their data are retained, in whole days
120 * (the AWS wire unit for the `retention` prop).
121 */
122 retentionInDays: number | undefined;
123 },
124 never,
125 Providers
126> {}
127
128/**
129 * A CloudWatch investigations *investigation group* — the one-time,
130 * per-Region container that configures who can run AI-assisted operational
131 * investigations, which IAM role is used to access telemetry, how long
132 * investigation data is retained, and how it is encrypted.
133 *
134 * You can have at most one investigation group per Region in an account, so
135 * replacements are performed delete-first.
136 * ### Creating an Investigation Group
137 * **Example:** Basic Investigation Group
138 * ```typescript
139 * import * as AIOps from "alchemy/AWS/AIOps";
140 * import * as IAM from "alchemy/AWS/IAM";
141 *
142 * const role = yield* IAM.Role("InvestigationsRole", {
143 * assumeRolePolicyDocument: {
144 * Version: "2012-10-17",
145 * Statement: [{
146 * Effect: "Allow",
147 * Principal: { Service: "aiops.amazonaws.com" },
148 * Action: ["sts:AssumeRole"],
149 * }],
150 * },
151 * managedPolicyArns: ["arn:aws:iam::aws:policy/AIOpsAssistantPolicy"],
152 * });
153 *
154 * const group = yield* AIOps.InvestigationGroup("Investigations", {
155 * roleArn: role.roleArn,
156 * });
157 * ```
158 *
159 * **Example:** Short Retention and Tag Boundaries
160 * ```typescript
161 * const group = yield* AIOps.InvestigationGroup("Investigations", {
162 * roleArn: role.roleArn,
163 * retention: "7 days",
164 * tagKeyBoundaries: ["Application"],
165 * tags: { Environment: "test" },
166 * });
167 * ```
168 *
169 * ### Resource Policy
170 * **Example:** Let CloudWatch Alarms Start Investigations
171 * ```typescript
172 * const group = yield* AIOps.InvestigationGroup("Investigations", {
173 * roleArn: role.roleArn,
174 * policy: [{
175 * Effect: "Allow",
176 * Principal: { Service: "aiops.alarms.cloudwatch.amazonaws.com" },
177 * Action: ["aiops:CreateInvestigation", "aiops:CreateInvestigationEvent"],
178 * Resource: "*",
179 * Condition: {
180 * StringEquals: { "aws:SourceAccount": "111122223333" },
181 * ArnLike: { "aws:SourceArn": "arn:aws:cloudwatch:us-east-1:111122223333:alarm:*" },
182 * },
183 * }],
184 * });
185 * ```
186 *
187 * @resource
188 */
189export const InvestigationGroup = Resource<InvestigationGroup>(
190 "AWS.AIOps.InvestigationGroup",
191);
192
193/**
194 * Bounded retry for `createInvestigationGroup` while the freshly created IAM
195 * role propagates — AIOps validates that it can assume the role at create
196 * time, which surfaces as a ValidationException/AccessDeniedException that
197 * mentions the role for the first seconds of the role's life.
198 *
199 * Explicitly annotated so the conditional `Retry.Return` type never leaks
200 * into declaration emit (it would widen `AWS.providers()` for consumers).
201 */
202const retryWhileRolePropagates = <A, R>(
203 self: Effect.Effect<A, aiops.CreateInvestigationGroupError, R>,
204): Effect.Effect<A, aiops.CreateInvestigationGroupError, R> =>
205 Effect.retry(self, {
206 while: (e) =>
207 (e._tag === "ValidationException" ||
208 e._tag === "AccessDeniedException") &&
209 /role/i.test(e.message ?? ""),
210 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(8)]),
211 });
212
213const sameStringArray = (l: readonly string[], r: readonly string[]) =>
214 l.length === r.length && l.every((v, i) => v === r[i]);
215
216export const InvestigationGroupProvider = () =>
217 Provider.effect(
218 InvestigationGroup,
219 Effect.gen(function* () {
220 const createName = Effect.fn(function* (
221 id: string,
222 props: Pick<InvestigationGroupProps, "name">,
223 ) {
224 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
225 });
226
227 const observeByArn = (arn: string) =>
228 aiops
229 .getInvestigationGroup({ identifier: arn })
230 .pipe(
231 Effect.catchTag("ResourceNotFoundException", () =>
232 Effect.succeed(undefined),
233 ),
234 );
235
236 // At most one investigation group exists per Region, so scanning the
237 // list for our name is a single cheap call.
238 const findArnByName = (name: string) =>
239 aiops.listInvestigationGroups.items({}).pipe(
240 Stream.runCollect,
241 Effect.map(
242 (items) => Array.from(items).find((g) => g.name === name)?.arn,
243 ),
244 );
245
246 const observe = Effect.fn(function* (
247 name: string,
248 arnHint: string | undefined,
249 ) {
250 if (arnHint !== undefined) {
251 const found = yield* observeByArn(arnHint);
252 if (found !== undefined) return found;
253 }
254 const arn = yield* findArnByName(name);
255 return arn === undefined ? undefined : yield* observeByArn(arn);
256 });
257
258 const observedTags = (arn: string) =>
259 aiops.listTagsForResource({ resourceArn: arn }).pipe(
260 Effect.map((r) => {
261 const tags: Record<string, string> = {};
262 for (const [key, value] of Object.entries(r.tags ?? {})) {
263 if (value !== undefined) tags[key] = value;
264 }
265 return tags;
266 }),
267 Effect.catchTag("ResourceNotFoundException", () =>
268 Effect.succeed({} as Record<string, string>),
269 ),
270 );
271
272 const toAttributes = (live: aiops.GetInvestigationGroupResponse) => ({
273 name: live.name!,
274 arn: live.arn!,
275 roleArn: live.roleArn,
276 retentionInDays: live.retentionInDays,
277 });
278
279 return InvestigationGroup.Provider.of({
280 stables: ["name", "arn"],
281 list: () =>
282 Effect.gen(function* () {
283 const items = yield* aiops.listInvestigationGroups
284 .items({})
285 .pipe(Stream.runCollect);
286 const groups: {
287 name: string;
288 arn: string;
289 roleArn: string | undefined;
290 retentionInDays: number | undefined;
291 }[] = [];
292 for (const item of Array.from(items)) {
293 if (item.arn === undefined) continue;
294 const live = yield* observeByArn(item.arn);
295 if (live?.arn !== undefined && live.name !== undefined) {
296 groups.push(toAttributes(live));
297 }
298 }
299 return groups;
300 }),
301 read: Effect.fn(function* ({ id, olds, output }) {
302 const name = output?.name ?? (yield* createName(id, olds ?? {}));
303 const live = yield* observe(name, output?.arn);
304 if (live?.arn === undefined || live.name === undefined) {
305 return undefined;
306 }
307 const attrs = toAttributes(live);
308 const tags = yield* observedTags(live.arn);
309 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
310 }),
311 diff: Effect.fn(function* ({ id, news, olds }) {
312 if (!isResolved(news)) return undefined;
313 const oldName = yield* createName(id, olds ?? {});
314 const newName = yield* createName(id, news ?? {});
315 // Only one investigation group may exist per Region, so a
316 // replacement must delete the old group before creating the new.
317 if (oldName !== newName) {
318 return { action: "replace", deleteFirst: true } as const;
319 }
320 // The retention period has no update API — replace to change it.
321 if (toWireDays(olds?.retention) !== toWireDays(news?.retention)) {
322 return { action: "replace", deleteFirst: true } as const;
323 }
324 // fall through: engine default update logic for mutable fields
325 }),
326 reconcile: Effect.fn(function* ({ id, news, output, session }) {
327 const name = output?.name ?? (yield* createName(id, news));
328 const internalTags = yield* createInternalTags(id);
329 const desiredTags = { ...news.tags, ...internalTags };
330
331 // 1. OBSERVE — cloud state is authoritative; output.arn is only a
332 // cache of the identifier.
333 let live = yield* observe(name, output?.arn);
334
335 // 2. ENSURE — create when missing. A ConflictException here means
336 // a concurrent create raced us (one group per Region): if a
337 // group with OUR name now exists we converge on it, otherwise
338 // the conflict is a real error (a foreign group occupies the
339 // Region's single slot) and is propagated.
340 if (live === undefined) {
341 live = yield* aiops
342 .createInvestigationGroup({
343 name,
344 roleArn: news.roleArn,
345 retentionInDays: toWireDays(news.retention),
346 encryptionConfiguration: news.encryptionConfiguration,
347 tagKeyBoundaries: news.tagKeyBoundaries,
348 chatbotNotificationChannel: news.chatbotNotificationChannel,
349 isCloudTrailEventHistoryEnabled:
350 news.isCloudTrailEventHistoryEnabled,
351 crossAccountConfigurations: news.crossAccountConfigurations,
352 tags: desiredTags,
353 })
354 .pipe(
355 retryWhileRolePropagates,
356 Effect.flatMap((created) =>
357 created.arn === undefined
358 ? observe(name, undefined)
359 : observeByArn(created.arn),
360 ),
361 Effect.catchTag("ConflictException", (error) =>
362 observe(name, undefined).pipe(
363 Effect.flatMap((existing) =>
364 existing === undefined
365 ? Effect.fail(error)
366 : Effect.succeed(existing),
367 ),
368 ),
369 ),
370 );
371 }
372
373 const arn = live?.arn ?? output?.arn;
374
375 // 3. SYNC — diff each OBSERVED mutable aspect against the desired
376 // state and PATCH only on drift. Aspects the user leaves
377 // undefined are unmanaged (left as observed).
378 if (live !== undefined && arn !== undefined) {
379 const update: Omit<
380 aiops.UpdateInvestigationGroupRequest,
381 "identifier"
382 > = {};
383 if (live.roleArn !== news.roleArn) {
384 update.roleArn = news.roleArn;
385 }
386 if (
387 news.encryptionConfiguration !== undefined &&
388 ((live.encryptionConfiguration?.type ?? "AWS_OWNED_KEY") !==
389 (news.encryptionConfiguration.type ?? "AWS_OWNED_KEY") ||
390 live.encryptionConfiguration?.kmsKeyId !==
391 news.encryptionConfiguration.kmsKeyId)
392 ) {
393 update.encryptionConfiguration = news.encryptionConfiguration;
394 }
395 if (
396 news.tagKeyBoundaries !== undefined &&
397 !sameStringArray(
398 live.tagKeyBoundaries ?? [],
399 news.tagKeyBoundaries,
400 )
401 ) {
402 update.tagKeyBoundaries = news.tagKeyBoundaries;
403 }
404 if (
405 news.isCloudTrailEventHistoryEnabled !== undefined &&
406 live.isCloudTrailEventHistoryEnabled !==
407 news.isCloudTrailEventHistoryEnabled
408 ) {
409 update.isCloudTrailEventHistoryEnabled =
410 news.isCloudTrailEventHistoryEnabled;
411 }
412 if (
413 news.chatbotNotificationChannel !== undefined &&
414 JSON.stringify(live.chatbotNotificationChannel ?? {}) !==
415 JSON.stringify(news.chatbotNotificationChannel)
416 ) {
417 update.chatbotNotificationChannel =
418 news.chatbotNotificationChannel;
419 }
420 if (
421 news.crossAccountConfigurations !== undefined &&
422 JSON.stringify(live.crossAccountConfigurations ?? []) !==
423 JSON.stringify(news.crossAccountConfigurations)
424 ) {
425 update.crossAccountConfigurations =
426 news.crossAccountConfigurations;
427 }
428 if (Object.keys(update).length > 0) {
429 yield* aiops.updateInvestigationGroup({
430 identifier: arn,
431 ...update,
432 });
433 live = (yield* observeByArn(arn)) ?? live;
434 }
435 }
436
437 // 3b. SYNC POLICY — diff the OBSERVED resource policy against the
438 // desired policy document and put/delete only on drift.
439 // `policy: undefined` leaves any existing policy unmanaged;
440 // `policy: []` deletes it.
441 if (arn !== undefined && news.policy !== undefined) {
442 const observedPolicy = yield* aiops
443 .getInvestigationGroupPolicy({ identifier: arn })
444 .pipe(
445 Effect.map((r) => r.policy),
446 Effect.catchTag("ResourceNotFoundException", () =>
447 Effect.succeed(undefined),
448 ),
449 );
450 if (news.policy.length === 0) {
451 if (observedPolicy !== undefined) {
452 yield* aiops
453 .deleteInvestigationGroupPolicy({ identifier: arn })
454 .pipe(
455 Effect.catchTag(
456 "ResourceNotFoundException",
457 () => Effect.void,
458 ),
459 );
460 }
461 } else {
462 const desiredPolicy = JSON.stringify({
463 Version: "2012-10-17",
464 Statement: news.policy,
465 });
466 const inSync = yield* Effect.sync(() => {
467 if (observedPolicy === undefined) return false;
468 try {
469 return (
470 JSON.stringify(JSON.parse(observedPolicy)) === desiredPolicy
471 );
472 } catch {
473 return false;
474 }
475 });
476 if (!inSync) {
477 yield* aiops.putInvestigationGroupPolicy({
478 identifier: arn,
479 policy: desiredPolicy,
480 });
481 }
482 }
483 }
484
485 // 3c. SYNC TAGS — diff against OBSERVED cloud tags so adoption
486 // converges (create-time tags only apply on first create).
487 if (arn !== undefined) {
488 const currentTags = yield* observedTags(arn);
489 const { upsert, removed } = diffTags(currentTags, desiredTags);
490 if (upsert.length > 0) {
491 yield* aiops.tagResource({
492 resourceArn: arn,
493 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
494 });
495 }
496 if (removed.length > 0) {
497 yield* aiops.untagResource({
498 resourceArn: arn,
499 tagKeys: removed,
500 });
501 }
502 }
503
504 yield* session.note(name);
505 return {
506 name: live?.name ?? name,
507 arn: arn!,
508 roleArn: live?.roleArn ?? news.roleArn,
509 retentionInDays:
510 live?.retentionInDays ?? toWireDays(news.retention),
511 };
512 }),
513 delete: Effect.fn(function* ({ output }) {
514 yield* aiops
515 .deleteInvestigationGroup({ identifier: output.arn })
516 .pipe(
517 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
518 );
519 }),
520 });
521 }),
522 );