1import * as AWS from "@/AWS";
2import { Group } from "@/AWS/ResourceGroups";
3import * as Test from "@/Test/Alchemy";
4import * as resourcegroups from "@distilled.cloud/aws/resource-groups";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const assertGroupGone = (groupName: string) =>
13 resourcegroups.getGroup({ Group: groupName }).pipe(
14 Effect.flatMap(() =>
15 Effect.fail(new Error(`group ${groupName} still exists`)),
16 ),
17 Effect.catchTag("NotFoundException", () => Effect.void),
18 Effect.retry({
19 while: (e) => e instanceof Error,
20 schedule: Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(10),
23 ]),
24 }),
25 );
26
27const tagQuery = (tagKey: string, tagValue: string) =>
28 JSON.stringify({
29 ResourceTypeFilters: ["AWS::AllSupported"],
30 TagFilters: [{ Key: tagKey, Values: [tagValue] }],
31 });
32
33describe(
34 "AWS.ResourceGroups.Group",
35 { tags: ["provider:aws", "provider:aws:resourcegroups", "live"] },
36 () => {
37 test.provider(
38 "creates, updates, and deletes a tag-based group",
39 (stack) =>
40 Effect.gen(function* () {
41 yield* stack.destroy();
42
43 const created = yield* stack.deploy(
44 Effect.gen(function* () {
45 const group = yield* Group("TagGroup", {
46 description: "Alchemy resource-groups test",
47 resourceQuery: {
48 type: "TAG_FILTERS_1_0",
49 query: tagQuery("alchemy-rg-test", "v1"),
50 },
51 tags: { purpose: "alchemy-test" },
52 });
53 return {
54 groupName: group.groupName,
55 groupArn: group.groupArn,
56 };
57 }),
58 );
59
60 // Verify out-of-band via distilled.
61 const observed = yield* resourcegroups.getGroup({
62 Group: created.groupName,
63 });
64 expect(observed.Group.GroupArn).toEqual(created.groupArn);
65 expect(observed.Group.Description).toEqual(
66 "Alchemy resource-groups test",
67 );
68
69 const query = yield* resourcegroups.getGroupQuery({
70 Group: created.groupName,
71 });
72 expect(query.GroupQuery?.ResourceQuery).toEqual({
73 Type: "TAG_FILTERS_1_0",
74 Query: tagQuery("alchemy-rg-test", "v1"),
75 });
76
77 const tags = yield* resourcegroups.getTags({
78 Arn: created.groupArn,
79 });
80 expect(tags.Tags?.purpose).toEqual("alchemy-test");
81 expect(tags.Tags?.["alchemy::id"]).toEqual("TagGroup");
82
83 // The query group is functional: enumerating members succeeds
84 // (nothing carries the probe tag, so it's simply empty).
85 const members = yield* resourcegroups.listGroupResources
86 .pages({ Group: created.groupName })
87 .pipe(
88 Stream.runCollect,
89 Effect.map((chunk) =>
90 Array.from(chunk).flatMap((page) => page.Resources ?? []),
91 ),
92 );
93 expect(members).toEqual([]);
94
95 // Update description, query, and tags in place — the ARN must
96 // survive (no replacement).
97 const updated = yield* stack.deploy(
98 Effect.gen(function* () {
99 const group = yield* Group("TagGroup", {
100 description: "Alchemy resource-groups test updated",
101 resourceQuery: {
102 type: "TAG_FILTERS_1_0",
103 query: tagQuery("alchemy-rg-test", "v2"),
104 },
105 tags: { purpose: "alchemy-test-updated" },
106 });
107 return {
108 groupName: group.groupName,
109 groupArn: group.groupArn,
110 };
111 }),
112 );
113 expect(updated.groupArn).toEqual(created.groupArn);
114
115 const afterUpdate = yield* resourcegroups.getGroup({
116 Group: created.groupName,
117 });
118 expect(afterUpdate.Group.Description).toEqual(
119 "Alchemy resource-groups test updated",
120 );
121 const queryAfterUpdate = yield* resourcegroups.getGroupQuery({
122 Group: created.groupName,
123 });
124 expect(queryAfterUpdate.GroupQuery?.ResourceQuery.Query).toEqual(
125 tagQuery("alchemy-rg-test", "v2"),
126 );
127 const tagsAfterUpdate = yield* resourcegroups.getTags({
128 Arn: created.groupArn,
129 });
130 expect(tagsAfterUpdate.Tags?.purpose).toEqual("alchemy-test-updated");
131
132 yield* stack.destroy();
133 yield* assertGroupGone(created.groupName);
134 }),
135 { timeout: 120_000 },
136 );
137
138 test.provider(
139 "replaces the group when the name changes",
140 (stack) =>
141 Effect.gen(function* () {
142 yield* stack.destroy();
143
144 const groupProps = {
145 resourceQuery: {
146 type: "TAG_FILTERS_1_0",
147 query: tagQuery("alchemy-rg-replace", "true"),
148 },
149 } as const;
150
151 const created = yield* stack.deploy(
152 Effect.gen(function* () {
153 const group = yield* Group("RenamedGroup", {
154 ...groupProps,
155 groupName: "alchemy-test-rg-original",
156 });
157 return { groupName: group.groupName };
158 }),
159 );
160 expect(created.groupName).toEqual("alchemy-test-rg-original");
161
162 const renamed = yield* stack.deploy(
163 Effect.gen(function* () {
164 const group = yield* Group("RenamedGroup", {
165 ...groupProps,
166 groupName: "alchemy-test-rg-renamed",
167 });
168 return { groupName: group.groupName };
169 }),
170 );
171 expect(renamed.groupName).toEqual("alchemy-test-rg-renamed");
172
173 // The old group is deleted by the replacement.
174 yield* assertGroupGone("alchemy-test-rg-original");
175
176 const observed = yield* resourcegroups.getGroup({
177 Group: "alchemy-test-rg-renamed",
178 });
179 expect(observed.Group.Name).toEqual("alchemy-test-rg-renamed");
180
181 yield* stack.destroy();
182 yield* assertGroupGone("alchemy-test-rg-renamed");
183 }),
184 { timeout: 120_000 },
185 );
186
187 test.provider(
188 "creates a configuration-based group",
189 (stack) =>
190 Effect.gen(function* () {
191 yield* stack.destroy();
192
193 const created = yield* stack.deploy(
194 Effect.gen(function* () {
195 const group = yield* Group("ConfigGroup", {
196 configuration: [
197 {
198 type: "AWS::ResourceGroups::Generic",
199 parameters: [
200 {
201 name: "allowed-resource-types",
202 values: ["AWS::EC2::CapacityReservation"],
203 },
204 ],
205 },
206 { type: "AWS::EC2::CapacityReservationPool" },
207 ],
208 });
209 return {
210 groupName: group.groupName,
211 groupArn: group.groupArn,
212 };
213 }),
214 );
215
216 const observed = yield* resourcegroups.getGroupConfiguration({
217 Group: created.groupName,
218 });
219 const types = (observed.GroupConfiguration?.Configuration ?? []).map(
220 (item) => item.Type,
221 );
222 expect(types).toContain("AWS::EC2::CapacityReservationPool");
223 expect(types).toContain("AWS::ResourceGroups::Generic");
224
225 // Re-deploying the identical configuration is a no-op (the
226 // order-insensitive fingerprint must not trigger a rejected
227 // putGroupConfiguration).
228 const second = yield* stack.deploy(
229 Effect.gen(function* () {
230 const group = yield* Group("ConfigGroup", {
231 configuration: [
232 {
233 type: "AWS::ResourceGroups::Generic",
234 parameters: [
235 {
236 name: "allowed-resource-types",
237 values: ["AWS::EC2::CapacityReservation"],
238 },
239 ],
240 },
241 { type: "AWS::EC2::CapacityReservationPool" },
242 ],
243 });
244 return { groupArn: group.groupArn };
245 }),
246 );
247 expect(second.groupArn).toEqual(created.groupArn);
248
249 yield* stack.destroy();
250 yield* assertGroupGone(created.groupName);
251 }),
252 { timeout: 120_000 },
253 );
254 },
255);