1import * as synthetics from "@distilled.cloud/aws/synthetics";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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
13export interface GroupProps {
14 /**
15 * Name of the group. Must match `^[0-9a-zA-Z_-]+$` and be at most 64
16 * characters. Group names must be unique in the account (groups are
17 * global resources visible from every Region).
18 * @default ${app}-${id}-${stage}-${suffix}
19 */
20 groupName?: string;
21 /**
22 * ARNs of the canaries associated with this group (as many as 10). The
23 * canaries must exist in the Region the group is managed from; membership
24 * is converged on every deploy (canaries added here are associated,
25 * canaries removed are disassociated).
26 */
27 members?: string[];
28 /**
29 * Tags to apply to the group. Merged with internal Alchemy tags.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface Group extends Resource<
35 "AWS.Synthetics.Group",
36 GroupProps,
37 {
38 /**
39 * Physical name of the group.
40 */
41 groupName: string;
42 /**
43 * ARN of the group. The ARN reflects the home Region the group was
44 * created in, but the group itself is a global resource.
45 */
46 groupArn: string;
47 /**
48 * Service-assigned unique ID of the group.
49 */
50 groupId: string;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * A CloudWatch Synthetics group — associates canaries (including
58 * cross-Region canaries) so you can view aggregated run results and manage
59 * them as a unit. A group can hold as many as 10 canaries, and an account
60 * can have as many as 20 groups.
61 * ### Creating Groups
62 * **Example:** Group of Canaries
63 * ```typescript
64 * import * as Synthetics from "alchemy/AWS/Synthetics";
65 *
66 * const group = yield* Synthetics.Group("ApiCanaries", {
67 * members: [checkoutCanary.canaryArn, searchCanary.canaryArn],
68 * });
69 * ```
70 *
71 * **Example:** Empty Group with Tags
72 * ```typescript
73 * const group = yield* Synthetics.Group("Fleet", {
74 * tags: { team: "platform" },
75 * });
76 * ```
77 *
78 * @resource
79 */
80export const Group = Resource<Group>("AWS.Synthetics.Group");
81
82export const GroupProvider = () =>
83 Provider.effect(
84 Group,
85 Effect.gen(function* () {
86 const createGroupName = Effect.fn(function* (
87 id: string,
88 props: { groupName?: string | undefined },
89 ) {
90 if (props.groupName) return props.groupName;
91 return yield* createPhysicalName({ id, maxLength: 64 });
92 });
93
94 const getGroupOrUndefined = Effect.fn(function* (identifier: string) {
95 return yield* synthetics.getGroup({ GroupIdentifier: identifier }).pipe(
96 Effect.map((r) => r.Group),
97 Effect.catchTag("ResourceNotFoundException", () =>
98 Effect.succeed(undefined),
99 ),
100 );
101 });
102
103 const listMembers = Effect.fn(function* (identifier: string) {
104 const pages = yield* synthetics.listGroupResources
105 .pages({ GroupIdentifier: identifier })
106 .pipe(Stream.runCollect);
107 return Array.from(pages).flatMap((page) => page.Resources ?? []);
108 });
109
110 const filterTags = (
111 tags: { [key: string]: string | undefined } | undefined,
112 ): Record<string, string> =>
113 Object.fromEntries(
114 Object.entries(tags ?? {}).filter(
115 (entry): entry is [string, string] => entry[1] !== undefined,
116 ),
117 );
118
119 const toAttributes = (
120 groupName: string,
121 group: synthetics.Group,
122 ): Group["Attributes"] => ({
123 groupName,
124 groupArn: group.Arn ?? "",
125 groupId: group.Id ?? "",
126 });
127
128 return Group.Provider.of({
129 stables: ["groupName", "groupArn", "groupId"],
130
131 list: () =>
132 Effect.gen(function* () {
133 const pages = yield* synthetics.listGroups
134 .pages({})
135 .pipe(Stream.runCollect);
136 return Array.from(pages)
137 .flatMap((page) => page.Groups ?? [])
138 .filter((group) => group.Name !== undefined)
139 .map((group) => ({
140 groupName: group.Name!,
141 groupArn: group.Arn ?? "",
142 groupId: group.Id ?? "",
143 }));
144 }),
145
146 read: Effect.fn(function* ({ id, olds, output }) {
147 const groupName =
148 output?.groupName ?? (yield* createGroupName(id, olds ?? {}));
149 const group = yield* getGroupOrUndefined(groupName);
150 if (group === undefined) return undefined;
151 const attrs = toAttributes(groupName, group);
152 const tags = filterTags(group.Tags);
153 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
154 }),
155
156 diff: Effect.fn(function* ({ id, news, olds }) {
157 if (!isResolved(news)) return undefined;
158 const oldName = yield* createGroupName(id, olds);
159 const newName = yield* createGroupName(id, news);
160 if (oldName !== newName) {
161 return { action: "replace" } as const;
162 }
163 // members and tags converge through reconcile
164 }),
165
166 reconcile: Effect.fn(function* ({ id, news, output, session }) {
167 const groupName =
168 output?.groupName ?? (yield* createGroupName(id, news));
169 const internalTags = yield* createInternalTags(id);
170 const desiredTags = { ...news.tags, ...internalTags };
171
172 // 1. OBSERVE — cloud state is authoritative.
173 let observed = yield* getGroupOrUndefined(groupName);
174
175 // 2. ENSURE — create if missing; tolerate a concurrent-create race.
176 if (observed === undefined) {
177 yield* synthetics
178 .createGroup({ Name: groupName, Tags: desiredTags })
179 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
180 observed = yield* getGroupOrUndefined(groupName);
181 yield* session.note(`created group ${groupName}`);
182 }
183 const groupArn = observed?.Arn ?? "";
184
185 // 3. SYNC members — diff observed canary associations against the
186 // desired members and apply only the delta. Association calls are
187 // idempotent per (group, canary) pair.
188 const observedMembers = yield* listMembers(groupName);
189 const desiredMembers = news.members ?? [];
190 const toAssociate = desiredMembers.filter(
191 (arn) => !observedMembers.includes(arn),
192 );
193 const toDisassociate = observedMembers.filter(
194 (arn) => !desiredMembers.includes(arn),
195 );
196 for (const ResourceArn of toAssociate) {
197 yield* synthetics
198 .associateResource({ GroupIdentifier: groupName, ResourceArn })
199 .pipe(
200 Effect.retry({
201 while: (e): boolean => e._tag === "ConflictException",
202 schedule: Schedule.max([
203 Schedule.fixed("2 seconds"),
204 Schedule.recurs(8),
205 ]),
206 }),
207 );
208 }
209 for (const ResourceArn of toDisassociate) {
210 yield* synthetics
211 .disassociateResource({
212 GroupIdentifier: groupName,
213 ResourceArn,
214 })
215 .pipe(
216 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
217 Effect.retry({
218 while: (e): boolean => e._tag === "ConflictException",
219 schedule: Schedule.max([
220 Schedule.fixed("2 seconds"),
221 Schedule.recurs(8),
222 ]),
223 }),
224 );
225 }
226 if (toAssociate.length > 0 || toDisassociate.length > 0) {
227 yield* session.note(
228 `synced group ${groupName} members (+${toAssociate.length}/-${toDisassociate.length})`,
229 );
230 }
231
232 // 4. SYNC tags — diff against OBSERVED cloud tags so adoption
233 // converges (create already applied them).
234 const currentTags = filterTags(observed?.Tags);
235 const { upsert, removed } = diffTags(currentTags, desiredTags);
236 if (upsert.length > 0) {
237 yield* synthetics.tagResource({
238 ResourceArn: groupArn,
239 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
240 });
241 }
242 if (removed.length > 0) {
243 yield* synthetics.untagResource({
244 ResourceArn: groupArn,
245 TagKeys: removed,
246 });
247 }
248
249 yield* session.note(groupName);
250 return {
251 groupName,
252 groupArn,
253 groupId: observed?.Id ?? "",
254 };
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 // The group does not need to be emptied first — deleting a group
259 // never deletes its canaries.
260 yield* synthetics
261 .deleteGroup({ GroupIdentifier: output.groupName })
262 .pipe(
263 Effect.retry({
264 while: (e): boolean => e._tag === "ConflictException",
265 schedule: Schedule.max([
266 Schedule.fixed("2 seconds"),
267 Schedule.recurs(8),
268 ]),
269 }),
270 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
271 );
272 }),
273 });
274 }),
275 );