1import * as AWS from "@/AWS";
2import { InvestigationGroup } from "@/AWS/AIOps";
3import type { PolicyStatement } from "@/AWS/IAM/Policy.ts";
4import { Role } from "@/AWS/IAM/Role.ts";
5import * as Test from "@/Test/Alchemy";
6import * as aiops from "@distilled.cloud/aws/aiops";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import type * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Ungated typed-error probe: prove the distilled error union carries the
16// not-found tag this provider's read/delete paths depend on. Runs in every
17// CI pass at near-zero cost, unlike the gated lifecycle below.
18test.provider(
19 "getInvestigationGroup on a nonexistent group fails with ResourceNotFoundException",
20 () =>
21 Effect.gen(function* () {
22 const error = yield* Effect.flip(
23 aiops.getInvestigationGroup({
24 identifier: "nonexistent0000000000",
25 }),
26 );
27 expect(error._tag).toBe("ResourceNotFoundException");
28 }),
29 { tags: ["provider:aws", "provider:aws:aiops", "live"] },
30);
31
32const findGroup = (arn: string) =>
33 aiops
34 .getInvestigationGroup({ identifier: arn })
35 .pipe(
36 Effect.catchTag("ResourceNotFoundException", () =>
37 Effect.succeed(undefined),
38 ),
39 );
40
41class GroupStillExists extends Data.TaggedError("GroupStillExists")<{
42 readonly arn: string;
43}> {}
44
45const assertGroupDeleted = (arn: string) =>
46 findGroup(arn).pipe(
47 Effect.flatMap((group) =>
48 group === undefined
49 ? Effect.void
50 : Effect.fail(new GroupStillExists({ arn })),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "GroupStillExists",
54 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
55 }),
56 );
57
58// An account can hold only ONE investigation group per Region, so the live
59// lifecycle would collide with any pre-existing group (or a concurrent CI
60// run). Gated behind AWS_TEST_AIOPS=1; always destroys what it created.
61test.provider.skipIf(!process.env.AWS_TEST_AIOPS)(
62 "create, update, replace (delete-first), delete investigation group",
63 (stack) =>
64 Effect.gen(function* () {
65 yield* stack.destroy();
66
67 const deployGroup = (props: {
68 retention: Duration.Input;
69 tagKeyBoundaries: string[];
70 isCloudTrailEventHistoryEnabled?: boolean;
71 policy?: PolicyStatement[];
72 tags: Record<string, string>;
73 }) =>
74 stack.deploy(
75 Effect.gen(function* () {
76 const role = yield* Role("AIOpsRole", {
77 assumeRolePolicyDocument: {
78 Version: "2012-10-17",
79 Statement: [
80 {
81 Effect: "Allow",
82 Principal: { Service: "aiops.amazonaws.com" },
83 Action: ["sts:AssumeRole"],
84 },
85 ],
86 },
87 managedPolicyArns: [
88 "arn:aws:iam::aws:policy/AIOpsAssistantPolicy",
89 ],
90 });
91 const group = yield* InvestigationGroup("Investigations", {
92 roleArn: role.roleArn,
93 ...props,
94 });
95 return { group };
96 }),
97 );
98
99 const { group } = yield* deployGroup({
100 retention: "7 days",
101 tagKeyBoundaries: ["Application"],
102 tags: { Environment: "test" },
103 });
104
105 expect(group.name).toBeDefined();
106 expect(group.arn).toContain(":investigation-group/");
107 expect(group.retentionInDays).toBe(7);
108
109 // Out-of-band verification via distilled.
110 const created = yield* findGroup(group.arn);
111 expect(created?.name).toBe(group.name);
112 expect(created?.retentionInDays).toBe(7);
113 expect(created?.tagKeyBoundaries).toEqual(["Application"]);
114 const tags = yield* aiops
115 .listTagsForResource({ resourceArn: group.arn })
116 .pipe(Effect.map((r) => r.tags ?? {}));
117 expect(tags.Environment).toBe("test");
118 expect(tags["alchemy::id"]).toBe("Investigations");
119
120 // Update mutable aspects in place: tag key boundaries, CloudTrail
121 // event history, resource policy, and tags.
122 const alarmPolicyStatement: PolicyStatement = {
123 Effect: "Allow",
124 Principal: { Service: "aiops.alarms.cloudwatch.amazonaws.com" },
125 Action: ["aiops:CreateInvestigation", "aiops:CreateInvestigationEvent"],
126 Resource: "*",
127 };
128 const { group: updated } = yield* deployGroup({
129 retention: "7 days",
130 tagKeyBoundaries: ["Application", "Service"],
131 isCloudTrailEventHistoryEnabled: false,
132 policy: [alarmPolicyStatement],
133 tags: { Environment: "test", Team: "obs" },
134 });
135 expect(updated.arn).toBe(group.arn);
136
137 const afterUpdate = yield* findGroup(group.arn);
138 expect(afterUpdate?.tagKeyBoundaries).toEqual(["Application", "Service"]);
139 expect(afterUpdate?.isCloudTrailEventHistoryEnabled).toBe(false);
140 const updatedTags = yield* aiops
141 .listTagsForResource({ resourceArn: group.arn })
142 .pipe(Effect.map((r) => r.tags ?? {}));
143 expect(updatedTags.Team).toBe("obs");
144
145 // Out-of-band: the resource policy landed on the group.
146 const policyAfterUpdate = yield* aiops.getInvestigationGroupPolicy({
147 identifier: group.arn,
148 });
149 expect(
150 JSON.parse(policyAfterUpdate.policy ?? "{}").Statement,
151 ).toMatchObject([alarmPolicyStatement]);
152
153 // `policy: []` deletes the attached resource policy.
154 yield* deployGroup({
155 retention: "7 days",
156 tagKeyBoundaries: ["Application", "Service"],
157 isCloudTrailEventHistoryEnabled: false,
158 policy: [],
159 tags: { Environment: "test", Team: "obs" },
160 });
161 const policyAfterDelete = yield* aiops
162 .getInvestigationGroupPolicy({ identifier: group.arn })
163 .pipe(
164 Effect.map((r) => r.policy),
165 Effect.catchTag("ResourceNotFoundException", () =>
166 Effect.succeed(undefined),
167 ),
168 );
169 expect(policyAfterDelete).toBeUndefined();
170
171 // The retention period has no update API — changing it replaces the
172 // group (delete-first, because only one group may exist per Region).
173 const { group: replaced } = yield* deployGroup({
174 retention: "14 days",
175 tagKeyBoundaries: ["Application", "Service"],
176 isCloudTrailEventHistoryEnabled: false,
177 tags: { Environment: "test", Team: "obs" },
178 });
179 expect(replaced.arn).not.toBe(group.arn);
180 expect(replaced.retentionInDays).toBe(14);
181 const afterReplace = yield* findGroup(replaced.arn);
182 expect(afterReplace?.retentionInDays).toBe(14);
183 yield* assertGroupDeleted(group.arn);
184
185 yield* stack.destroy();
186 yield* assertGroupDeleted(replaced.arn);
187 }),
188 {
189 tags: ["provider:aws", "provider:aws:aiops", "provider:aws:iam", "live"],
190 timeout: 600_000,
191 },
192);