1import * as shield from "@distilled.cloud/aws/shield";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17/** How Shield Advanced aggregates traffic across a protection group. */
18export type ProtectionGroupAggregation = "SUM" | "MEAN" | "MAX";
19
20/** Which protected resources are members of the group. */
21export type ProtectionGroupPattern = "ALL" | "ARBITRARY" | "BY_RESOURCE_TYPE";
22
23/** Resource type selector for `BY_RESOURCE_TYPE` groups. */
24export type ProtectedResourceType =
25 | "CLOUDFRONT_DISTRIBUTION"
26 | "ROUTE_53_HOSTED_ZONE"
27 | "ELASTIC_IP_ALLOCATION"
28 | "CLASSIC_LOAD_BALANCER"
29 | "APPLICATION_LOAD_BALANCER"
30 | "GLOBAL_ACCELERATOR";
31
32export interface ProtectionGroupProps {
33 /**
34 * Identifier of the protection group (alphanumeric and `-`, max 36 chars).
35 * Immutable — changing it replaces the group. If omitted, a unique id is
36 * generated.
37 */
38 protectionGroupId?: string;
39 /**
40 * How Shield Advanced aggregates traffic across the group: `SUM` for
41 * combined volume, `MEAN` for per-resource average, `MAX` for the highest
42 * single-resource volume. Mutable.
43 */
44 aggregation: ProtectionGroupAggregation;
45 /**
46 * Which protected resources are members: `ALL` protected resources,
47 * an `ARBITRARY` list (set `members`), or all of one `BY_RESOURCE_TYPE`
48 * (set `resourceType`). Mutable.
49 */
50 pattern: ProtectionGroupPattern;
51 /**
52 * Resource type included in the group. Required when `pattern` is
53 * `BY_RESOURCE_TYPE`; omit otherwise.
54 */
55 resourceType?: ProtectedResourceType;
56 /**
57 * ARNs of the protected resources in the group. Required when `pattern` is
58 * `ARBITRARY`; omit otherwise.
59 */
60 members?: string[];
61 /**
62 * User-defined tags. Alchemy ownership tags are merged in automatically.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface ProtectionGroup extends Resource<
68 "AWS.Shield.ProtectionGroup",
69 ProtectionGroupProps,
70 {
71 /** Identifier of the protection group. */
72 protectionGroupId: string;
73 /** ARN of the protection group. */
74 protectionGroupArn: string;
75 /** Traffic aggregation mode. */
76 aggregation: string;
77 /** Membership pattern. */
78 pattern: string;
79 /** Resource type for `BY_RESOURCE_TYPE` groups. */
80 resourceType: string | undefined;
81 /** Member resource ARNs for `ARBITRARY` groups. */
82 members: string[];
83 /** Tags on the protection group (including Alchemy ownership tags). */
84 tags: Record<string, string>;
85 },
86 never,
87 Providers
88> {}
89
90/**
91 * An AWS Shield Advanced Protection Group — a collective of protected
92 * resources whose traffic Shield Advanced monitors as a unit, improving
93 * detection accuracy and reducing false positives.
94 *
95 * Requires an active Shield Advanced subscription ($3,000/month with a 1-year
96 * commitment); without one every call fails with the typed
97 * `SubscriptionNotFound` error.
98 *
99 * ### Grouping Protections
100 * **Example:** Group All Protected Resources
101 * ```typescript
102 * const group = yield* Shield.ProtectionGroup("AllResources", {
103 * aggregation: "SUM",
104 * pattern: "ALL",
105 * });
106 * ```
107 *
108 * **Example:** Group by Resource Type
109 * ```typescript
110 * const group = yield* Shield.ProtectionGroup("Distributions", {
111 * aggregation: "MAX",
112 * pattern: "BY_RESOURCE_TYPE",
113 * resourceType: "CLOUDFRONT_DISTRIBUTION",
114 * });
115 * ```
116 *
117 * **Example:** Arbitrary Member List
118 * ```typescript
119 * const group = yield* Shield.ProtectionGroup("Fleet", {
120 * aggregation: "MEAN",
121 * pattern: "ARBITRARY",
122 * members: [distribution.distributionArn],
123 * tags: { team: "platform" },
124 * });
125 * ```
126 */
127export const ProtectionGroup = Resource<ProtectionGroup>(
128 "AWS.Shield.ProtectionGroup",
129);
130
131const observeGroup = (protectionGroupId: string) =>
132 shield.describeProtectionGroup({ ProtectionGroupId: protectionGroupId }).pipe(
133 Effect.map((r) => r.ProtectionGroup),
134 Effect.catchTag(["ResourceNotFoundException", "SubscriptionNotFound"], () =>
135 Effect.succeed(undefined),
136 ),
137 );
138
139const toTagRecord = (tags: shield.Tag[] | undefined): Record<string, string> =>
140 tagRecord(
141 (tags ?? []).flatMap((t) =>
142 t.Key !== undefined && t.Value !== undefined
143 ? [{ Key: t.Key, Value: t.Value }]
144 : [],
145 ),
146 );
147
148const readGroupTags = (protectionGroupArn: string) =>
149 shield.listTagsForResource({ ResourceARN: protectionGroupArn }).pipe(
150 Effect.map((r) => toTagRecord(r.Tags)),
151 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
152 );
153
154const buildAttrs = (
155 group: shield.ProtectionGroup,
156 tags: Record<string, string>,
157) => ({
158 protectionGroupId: group.ProtectionGroupId,
159 protectionGroupArn: group.ProtectionGroupArn!,
160 aggregation: group.Aggregation,
161 pattern: group.Pattern,
162 resourceType: group.ResourceType,
163 members: [...group.Members],
164 tags,
165});
166
167const sameMembers = (a: readonly string[], b: readonly string[]) =>
168 a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
169
170export const ProtectionGroupProvider = () =>
171 Provider.effect(
172 ProtectionGroup,
173 Effect.gen(function* () {
174 const toGroupId = (id: string, props: { protectionGroupId?: string }) =>
175 props.protectionGroupId
176 ? Effect.succeed(props.protectionGroupId)
177 : createPhysicalName({ id, maxLength: 36 });
178
179 const syncTags = Effect.fn(function* (
180 protectionGroupArn: string,
181 desiredTags: Record<string, string>,
182 ) {
183 const observedTags = yield* readGroupTags(protectionGroupArn);
184 const { upsert, removed } = diffTags(observedTags, desiredTags);
185 if (upsert.length > 0) {
186 yield* shield.tagResource({
187 ResourceARN: protectionGroupArn,
188 Tags: upsert,
189 });
190 }
191 if (removed.length > 0) {
192 yield* shield.untagResource({
193 ResourceARN: protectionGroupArn,
194 TagKeys: removed,
195 });
196 }
197 });
198
199 return {
200 stables: ["protectionGroupId", "protectionGroupArn"],
201
202 diff: Effect.fn(function* ({ id, olds, news }) {
203 if (!isResolved(news)) return;
204 if (olds === undefined) return;
205 if ((yield* toGroupId(id, olds)) !== (yield* toGroupId(id, news))) {
206 return { action: "replace" } as const;
207 }
208 }),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const groupId =
212 output?.protectionGroupId ?? (yield* toGroupId(id, olds ?? {}));
213 const group = yield* observeGroup(groupId);
214 if (!group?.ProtectionGroupArn) return undefined;
215 const tags = yield* readGroupTags(group.ProtectionGroupArn);
216 const attrs = buildAttrs(group, tags);
217 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
218 }),
219
220 reconcile: Effect.fn(function* ({ id, news, session }) {
221 const groupId = yield* toGroupId(id, news);
222 const internalTags = yield* createInternalTags(id);
223 const desiredTags = { ...news.tags, ...internalTags };
224
225 // 1. OBSERVE — cloud state is authoritative.
226 let group = yield* observeGroup(groupId);
227
228 // 2. ENSURE — create if missing; tolerate the AlreadyExists race.
229 if (!group) {
230 yield* shield
231 .createProtectionGroup({
232 ProtectionGroupId: groupId,
233 Aggregation: news.aggregation,
234 Pattern: news.pattern,
235 ResourceType: news.resourceType,
236 Members: news.members,
237 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
238 Key,
239 Value,
240 })),
241 })
242 .pipe(
243 Effect.catchTag(
244 "ResourceAlreadyExistsException",
245 () => Effect.void,
246 ),
247 );
248 group = yield* observeGroup(groupId);
249 if (!group?.ProtectionGroupArn) {
250 return yield* Effect.fail(
251 new Error(
252 `Failed to create or read Shield protection group ${groupId}`,
253 ),
254 );
255 }
256 } else {
257 // 3. SYNC settings — diff observed against desired; update only
258 // on an actual delta.
259 const changed =
260 group.Aggregation !== news.aggregation ||
261 group.Pattern !== news.pattern ||
262 (group.ResourceType ?? undefined) !==
263 (news.resourceType ?? undefined) ||
264 !sameMembers(group.Members ?? [], news.members ?? []);
265 if (changed) {
266 yield* shield.updateProtectionGroup({
267 ProtectionGroupId: groupId,
268 Aggregation: news.aggregation,
269 Pattern: news.pattern,
270 ResourceType: news.resourceType,
271 Members: news.members,
272 });
273 group = yield* observeGroup(groupId);
274 }
275 }
276
277 // 3b. SYNC tags — diff against OBSERVED cloud tags.
278 yield* syncTags(group!.ProtectionGroupArn!, desiredTags);
279
280 // 4. RETURN fresh attributes.
281 yield* session.note(group!.ProtectionGroupArn!);
282 return buildAttrs(group!, desiredTags);
283 }),
284
285 // Enumerate every protection group in the account. Without a Shield
286 // Advanced subscription the account cannot have any groups.
287 list: () =>
288 Effect.gen(function* () {
289 const groups = yield* shield.listProtectionGroups.pages({}).pipe(
290 Stream.runCollect,
291 Effect.map((chunk) =>
292 Array.from(chunk).flatMap(
293 (page) => page.ProtectionGroups ?? [],
294 ),
295 ),
296 Effect.catchTag("SubscriptionNotFound", () => Effect.succeed([])),
297 );
298 return yield* Effect.forEach(
299 groups.filter((g) => g.ProtectionGroupArn != null),
300 (group) =>
301 Effect.gen(function* () {
302 const tags = yield* readGroupTags(group.ProtectionGroupArn!);
303 return buildAttrs(group, tags);
304 }),
305 { concurrency: 5 },
306 );
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* shield
311 .deleteProtectionGroup({
312 ProtectionGroupId: output.protectionGroupId,
313 })
314 .pipe(
315 Effect.catchTag(
316 ["ResourceNotFoundException", "SubscriptionNotFound"],
317 () => Effect.void,
318 ),
319 );
320 }),
321 };
322 }),
323 );