1import * as resourcegroups from "@distilled.cloud/aws/resource-groups";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12
13/**
14 * The query that determines which AWS resources are members of a group.
15 */
16export interface GroupResourceQuery {
17 /**
18 * The type of the query.
19 *
20 * - `TAG_FILTERS_1_0` — a tag-based query; `query` is a JSON string with
21 * `ResourceTypeFilters` and `TagFilters`.
22 * - `CLOUDFORMATION_STACK_1_0` — a CloudFormation stack-based query;
23 * `query` is a JSON string with `ResourceTypeFilters` and `StackIdentifier`.
24 */
25 type: "TAG_FILTERS_1_0" | "CLOUDFORMATION_STACK_1_0";
26
27 /**
28 * The query expression as a JSON string. See the *Resource Groups User
29 * Guide* for the query syntax of each query type.
30 */
31 query: string;
32}
33
34/**
35 * A parameter of a group configuration item.
36 */
37export interface GroupConfigurationParameter {
38 /**
39 * The name of the parameter, e.g. `allowed-resource-types`.
40 */
41 name: string;
42
43 /**
44 * The values of the parameter.
45 */
46 values?: string[];
47}
48
49/**
50 * An item of an AWS service configuration attached to a resource group.
51 */
52export interface GroupConfigurationItem {
53 /**
54 * The configuration type, e.g. `AWS::ResourceGroups::Generic` or
55 * `AWS::EC2::CapacityReservationPool`.
56 */
57 type: string;
58
59 /**
60 * Parameters of the configuration item.
61 */
62 parameters?: GroupConfigurationParameter[];
63}
64
65export interface GroupProps {
66 /**
67 * Name of the resource group. If omitted, a unique name is generated from
68 * the app, stage and logical ID. Changing it replaces the group.
69 */
70 groupName?: string;
71
72 /**
73 * A description of the resource group.
74 */
75 description?: string;
76
77 /**
78 * The resource query that determines the group's members. Mutually
79 * exclusive with `configuration`.
80 */
81 resourceQuery?: GroupResourceQuery;
82
83 /**
84 * An AWS service configuration attached to the group (a
85 * configuration-based group). Mutually exclusive with `resourceQuery`.
86 * Adding or removing the configuration entirely replaces the group.
87 */
88 configuration?: GroupConfigurationItem[];
89
90 /**
91 * User tags to attach to the group. Merged with internal Alchemy tags.
92 */
93 tags?: Record<string, string>;
94}
95
96export interface Group extends Resource<
97 "AWS.ResourceGroups.Group",
98 GroupProps,
99 {
100 /** The name of the resource group. */
101 groupName: string;
102 /** The ARN of the resource group. */
103 groupArn: string;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * An AWS Resource Groups group — a collection of AWS resources defined by a
111 * tag-based query, a CloudFormation stack query, or an attached service
112 * configuration.
113 *
114 * Deleting a group never deletes its member resources; it only deletes the
115 * group structure.
116 *
117 * ### Creating Groups
118 * **Example:** Tag-based Group
119 * ```typescript
120 * import * as ResourceGroups from "alchemy/AWS/ResourceGroups";
121 *
122 * const group = yield* ResourceGroups.Group("EnvGroup", {
123 * description: "All resources tagged env=prod",
124 * resourceQuery: {
125 * type: "TAG_FILTERS_1_0",
126 * query: JSON.stringify({
127 * ResourceTypeFilters: ["AWS::AllSupported"],
128 * TagFilters: [{ Key: "env", Values: ["prod"] }],
129 * }),
130 * },
131 * });
132 * ```
133 *
134 * **Example:** CloudFormation Stack Group
135 * ```typescript
136 * const group = yield* ResourceGroups.Group("StackGroup", {
137 * resourceQuery: {
138 * type: "CLOUDFORMATION_STACK_1_0",
139 * query: JSON.stringify({
140 * ResourceTypeFilters: ["AWS::AllSupported"],
141 * StackIdentifier: stackArn,
142 * }),
143 * },
144 * });
145 * ```
146 *
147 * ### Service Configurations
148 * **Example:** Capacity Reservation Pool Group
149 * ```typescript
150 * const pool = yield* ResourceGroups.Group("ReservationPool", {
151 * configuration: [
152 * {
153 * type: "AWS::ResourceGroups::Generic",
154 * parameters: [
155 * {
156 * name: "allowed-resource-types",
157 * values: ["AWS::EC2::CapacityReservation"],
158 * },
159 * ],
160 * },
161 * { type: "AWS::EC2::CapacityReservationPool" },
162 * ],
163 * });
164 * ```
165 *
166 * ### Tagging
167 * **Example:** Group with Tags
168 * ```typescript
169 * const group = yield* ResourceGroups.Group("TaggedGroup", {
170 * resourceQuery: {
171 * type: "TAG_FILTERS_1_0",
172 * query: JSON.stringify({
173 * ResourceTypeFilters: ["AWS::AllSupported"],
174 * TagFilters: [{ Key: "team", Values: ["platform"] }],
175 * }),
176 * },
177 * tags: { team: "platform" },
178 * });
179 * ```
180 *
181 * @resource
182 */
183export const Group = Resource<Group>("AWS.ResourceGroups.Group");
184
185/**
186 * Raised when a `Group` is configured with both `resourceQuery` and
187 * `configuration`. A resource group is defined by exactly one of the two.
188 */
189export class ResourceGroupDefinitionConflict extends Data.TaggedError(
190 "ResourceGroupDefinitionConflict",
191)<{ message: string }> {}
192
193const validateDefinition = (props: GroupProps) =>
194 props.resourceQuery !== undefined && props.configuration !== undefined
195 ? Effect.fail(
196 new ResourceGroupDefinitionConflict({
197 message:
198 "resourceQuery and configuration are mutually exclusive — a resource group is defined by exactly one of the two.",
199 }),
200 )
201 : Effect.void;
202
203/** Wire shape of the resource query, built from props. */
204const toResourceQuery = (query: GroupResourceQuery) => ({
205 Type: query.type,
206 Query: query.query,
207});
208
209/** Wire shape of the service configuration, built from props. */
210const toConfiguration = (configuration: GroupConfigurationItem[]) =>
211 configuration.map((item) => ({
212 Type: item.type,
213 Parameters: item.parameters?.map((p) => ({
214 Name: p.name,
215 Values: p.values,
216 })),
217 }));
218
219/**
220 * Canonical, order-insensitive fingerprint of a service configuration.
221 * The API returns configuration items (and their parameters) in arbitrary
222 * order, so both sides are sorted before comparison.
223 */
224const configurationFingerprint = (
225 items: readonly {
226 Type: string;
227 Parameters?: readonly { Name: string; Values?: readonly string[] }[];
228 }[],
229) =>
230 JSON.stringify(
231 items
232 .map((item) => ({
233 Type: item.Type,
234 Parameters: (item.Parameters ?? [])
235 .map((p) => ({ Name: p.Name, Values: p.Values ?? [] }))
236 .sort((a, b) => a.Name.localeCompare(b.Name)),
237 }))
238 .sort((a, b) => a.Type.localeCompare(b.Type)),
239 );
240
241export const GroupProvider = () =>
242 Provider.effect(
243 Group,
244 Effect.gen(function* () {
245 const createName = Effect.fn(function* (id: string, props: GroupProps) {
246 // Group names must not start with 'AWS' (reserved by the service).
247 return (
248 props.groupName ??
249 (yield* createPhysicalName({ id, forbiddenPrefixes: ["aws"] }))
250 );
251 });
252
253 const readGroupTags = (groupArn: string) =>
254 resourcegroups.getTags({ Arn: groupArn }).pipe(
255 Effect.map((r) => (r.Tags ?? {}) as Record<string, string>),
256 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
257 );
258
259 return Group.Provider.of({
260 stables: ["groupName", "groupArn"],
261 list: () =>
262 resourcegroups.listGroups.pages({}).pipe(
263 Stream.runCollect,
264 Effect.map((chunk) =>
265 Array.from(chunk).flatMap((page) =>
266 (page.GroupIdentifiers ?? [])
267 .filter((g) => g.GroupName != null && g.GroupArn != null)
268 .map((g) => ({
269 groupName: g.GroupName!,
270 groupArn: g.GroupArn!,
271 })),
272 ),
273 ),
274 ),
275 read: Effect.fn(function* ({ id, olds, output }) {
276 const groupName =
277 output?.groupName ?? (yield* createName(id, olds ?? {}));
278 const found = yield* resourcegroups
279 .getGroup({ Group: groupName })
280 .pipe(
281 Effect.map((r) => r.Group),
282 Effect.catchTag("NotFoundException", () =>
283 Effect.succeed(undefined),
284 ),
285 );
286 if (!found) return undefined;
287 const attrs = {
288 groupName,
289 groupArn: found.GroupArn,
290 };
291 const tags = yield* readGroupTags(found.GroupArn);
292 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
293 }),
294 diff: Effect.fn(function* ({ id, news, olds }) {
295 if (!isResolved(news)) return undefined;
296 yield* validateDefinition(news);
297 const oldName = yield* createName(id, olds);
298 const newName = yield* createName(id, news);
299 if (oldName !== newName) return { action: "replace" } as const;
300 // A group is either query-based or configuration-based for its
301 // whole life — switching between the two requires replacement.
302 if (
303 (olds.configuration !== undefined) !==
304 (news.configuration !== undefined)
305 ) {
306 return { action: "replace" } as const;
307 }
308 return undefined;
309 }),
310 reconcile: Effect.fn(function* ({ id, news, output, session }) {
311 yield* validateDefinition(news);
312 const groupName = output?.groupName ?? (yield* createName(id, news));
313 const internalTags = yield* createInternalTags(id);
314
315 // OBSERVE — cloud state is authoritative; output is only a name cache.
316 let live = yield* resourcegroups.getGroup({ Group: groupName }).pipe(
317 Effect.map((r) => r.Group),
318 Effect.catchTag("NotFoundException", () =>
319 Effect.succeed(undefined),
320 ),
321 );
322
323 // ENSURE — create if missing; tolerate the already-exists race.
324 if (live === undefined) {
325 live = yield* resourcegroups
326 .createGroup({
327 Name: groupName,
328 Description: news.description,
329 ResourceQuery: news.resourceQuery
330 ? toResourceQuery(news.resourceQuery)
331 : undefined,
332 Configuration: news.configuration
333 ? toConfiguration(news.configuration)
334 : undefined,
335 Tags: { ...news.tags, ...internalTags },
336 })
337 .pipe(
338 Effect.map((r) => r.Group),
339 Effect.catchTag("GroupAlreadyExists", () =>
340 resourcegroups
341 .getGroup({ Group: groupName })
342 .pipe(Effect.map((r) => r.Group)),
343 ),
344 );
345 }
346 const groupArn = live.GroupArn;
347
348 // SYNC description — diff observed against desired; empty string
349 // clears. Fields omitted from updateGroup are left unchanged.
350 const desiredDescription = news.description ?? "";
351 if ((live.Description ?? "") !== desiredDescription) {
352 yield* resourcegroups.updateGroup({
353 Group: groupName,
354 Description: desiredDescription,
355 });
356 }
357
358 // SYNC resource query — only meaningful for query-based groups
359 // (getGroupQuery rejects configuration-based groups).
360 if (news.resourceQuery !== undefined) {
361 const desiredQuery = toResourceQuery(news.resourceQuery);
362 const observedQuery = yield* resourcegroups
363 .getGroupQuery({ Group: groupName })
364 .pipe(
365 Effect.map((r) => r.GroupQuery?.ResourceQuery),
366 Effect.catchTag("NotFoundException", () =>
367 Effect.succeed(undefined),
368 ),
369 );
370 if (
371 observedQuery?.Type !== desiredQuery.Type ||
372 observedQuery?.Query !== desiredQuery.Query
373 ) {
374 yield* resourcegroups.updateGroupQuery({
375 Group: groupName,
376 ResourceQuery: desiredQuery,
377 });
378 }
379 }
380
381 // SYNC service configuration — order-insensitive comparison, the
382 // API returns configuration items in arbitrary order.
383 if (news.configuration !== undefined) {
384 const desiredConfiguration = toConfiguration(news.configuration);
385 const observedConfiguration = yield* resourcegroups
386 .getGroupConfiguration({ Group: groupName })
387 .pipe(
388 Effect.map((r) => r.GroupConfiguration?.Configuration ?? []),
389 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
390 );
391 if (
392 configurationFingerprint(observedConfiguration) !==
393 configurationFingerprint(desiredConfiguration)
394 ) {
395 yield* resourcegroups.putGroupConfiguration({
396 Group: groupName,
397 Configuration: desiredConfiguration,
398 });
399 }
400 }
401
402 // SYNC tags — diff against OBSERVED cloud tags so adoption
403 // converges (createGroup tags only apply on first create).
404 const observedTags = yield* readGroupTags(groupArn);
405 const { upsert, removed } = diffTags(observedTags, {
406 ...news.tags,
407 ...internalTags,
408 });
409 if (upsert.length > 0) {
410 yield* resourcegroups.tag({
411 Arn: groupArn,
412 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
413 });
414 }
415 if (removed.length > 0) {
416 yield* resourcegroups.untag({ Arn: groupArn, Keys: removed });
417 }
418
419 yield* session.note(groupName);
420 return {
421 groupName,
422 groupArn,
423 };
424 }),
425 delete: Effect.fn(function* ({ output }) {
426 yield* resourcegroups
427 .deleteGroup({ Group: output.groupName })
428 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
429 }),
430 });
431 }),
432 );