1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as ResourceGroups from "@/AWS/ResourceGroups";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class RGTestFunction extends Lambda.Function<Lambda.Function>()(
15 "RGTestFunction",
16) {}
17
18/** Return a typed error tag + message instead of failing the route. */
19const tagOf = <A extends object, E extends { _tag: string; message?: string }>(
20 eff: Effect.Effect<A, E>,
21) =>
22 eff.pipe(
23 Effect.map((ok) => ({ ok })),
24 Effect.catch((e) =>
25 Effect.succeed({ errorTag: e._tag, message: e.message }),
26 ),
27 );
28
29export default RGTestFunction.make(
30 {
31 main,
32 functionUrl: true,
33 timeout: Duration.seconds(30),
34 },
35 Effect.gen(function* () {
36 // The group the group-scoped bindings are bound to. A capacity
37 // reservation pool group is one of the config types GroupResources /
38 // UngroupResources accept, and creating it is free (the pool starts
39 // empty and never acquires capacity).
40 const pool = yield* ResourceGroups.Group("BindingsPoolGroup", {
41 description: "alchemy resource-groups bindings fixture pool",
42 configuration: [
43 {
44 type: "AWS::ResourceGroups::Generic",
45 parameters: [
46 {
47 name: "allowed-resource-types",
48 values: ["AWS::EC2::CapacityReservation"],
49 },
50 ],
51 },
52 { type: "AWS::EC2::CapacityReservationPool" },
53 ],
54 });
55
56 // Role a tag-sync task would run as (tag-sync itself is gated: it is
57 // part of Group Lifecycle Events, in maintenance mode since 2026-07-30
58 // and closed to new customers, and even entitled accounts need an
59 // application group which CreateGroup cannot make — the /start-tag-sync
60 // route asserts the typed rejection).
61 const syncRole = yield* IAM.Role("BindingsTagSyncRole", {
62 assumeRolePolicyDocument: {
63 Version: "2012-10-17",
64 Statement: [
65 {
66 Effect: "Allow",
67 Principal: { Service: "resource-groups.amazonaws.com" },
68 Action: ["sts:AssumeRole"],
69 },
70 ],
71 },
72 });
73
74 // Event source: subscribe the host to group lifecycle events. The
75 // deploy proves the EventBridge rule + invoke permission wiring.
76 yield* ResourceGroups.consumeGroupEvents(
77 { kinds: ["state-change", "membership-change"] },
78 (events) =>
79 Stream.runForEach(events, (event) =>
80 Effect.log(`group event: ${event["detail-type"]}`),
81 ),
82 );
83
84 // --- group-scoped bindings ---
85 const listGroupResources = yield* ResourceGroups.ListGroupResources(pool);
86 const listGroupingStatuses =
87 yield* ResourceGroups.ListGroupingStatuses(pool);
88 const groupResources = yield* ResourceGroups.GroupResources(pool);
89 const ungroupResources = yield* ResourceGroups.UngroupResources(pool);
90 const startTagSyncTask = yield* ResourceGroups.StartTagSyncTask(
91 pool,
92 syncRole,
93 );
94
95 // --- account-level bindings ---
96 const searchResources = yield* ResourceGroups.SearchResources();
97 const getAccountSettings = yield* ResourceGroups.GetAccountSettings();
98 const listTagSyncTasks = yield* ResourceGroups.ListTagSyncTasks();
99 const getTagSyncTask = yield* ResourceGroups.GetTagSyncTask();
100 const cancelTagSyncTask = yield* ResourceGroups.CancelTagSyncTask();
101
102 const bound = {
103 listGroupResources,
104 listGroupingStatuses,
105 groupResources,
106 ungroupResources,
107 startTagSyncTask,
108 searchResources,
109 getAccountSettings,
110 listTagSyncTasks,
111 getTagSyncTask,
112 cancelTagSyncTask,
113 };
114
115 return {
116 fetch: Effect.gen(function* () {
117 const request = yield* HttpServerRequest;
118 const url = new URL(request.originalUrl);
119 const pathname = url.pathname;
120
121 if (request.method === "GET" && pathname === "/bindings") {
122 return yield* HttpServerResponse.json({
123 bound: Object.keys(bound),
124 });
125 }
126
127 // Group-scoped read: the group name is injected from the binding.
128 if (request.method === "GET" && pathname === "/members") {
129 const { Resources } = yield* listGroupResources();
130 return yield* HttpServerResponse.json({
131 arns: (Resources ?? []).map((r) => r.Identifier?.ResourceArn),
132 });
133 }
134
135 // Group-scoped write: the API accepts the request and reports the
136 // per-resource outcome (a Lambda ARN is not poolable, so it lands
137 // in Failed with a typed error code — proving the full wire loop).
138 if (request.method === "POST" && pathname === "/group") {
139 const { arn } = (yield* request.json) as { arn: string };
140 const out = yield* groupResources({ ResourceArns: [arn] });
141 return yield* HttpServerResponse.json({
142 succeeded: out.Succeeded ?? [],
143 failedCodes: (out.Failed ?? []).map((f) => f.ErrorCode),
144 pending: (out.Pending ?? []).map((p) => p.ResourceArn),
145 });
146 }
147 if (request.method === "POST" && pathname === "/ungroup") {
148 const { arn } = (yield* request.json) as { arn: string };
149 const out = yield* ungroupResources({ ResourceArns: [arn] });
150 return yield* HttpServerResponse.json({
151 succeeded: out.Succeeded ?? [],
152 failedCodes: (out.Failed ?? []).map((f) => f.ErrorCode),
153 });
154 }
155
156 // Grouping statuses only exist for application groups (which
157 // CreateGroup cannot make) — the typed rejection proves the wire.
158 if (request.method === "GET" && pathname === "/grouping-statuses") {
159 return yield* HttpServerResponse.json(
160 yield* tagOf(listGroupingStatuses()),
161 );
162 }
163
164 // Account-level ad-hoc query.
165 if (request.method === "GET" && pathname === "/search") {
166 const { ResourceIdentifiers } = yield* searchResources({
167 ResourceQuery: {
168 Type: "TAG_FILTERS_1_0",
169 Query: JSON.stringify({
170 ResourceTypeFilters: ["AWS::AllSupported"],
171 TagFilters: [
172 { Key: "alchemy::id", Values: ["RGTestFunction"] },
173 ],
174 }),
175 },
176 });
177 return yield* HttpServerResponse.json({
178 arns: (ResourceIdentifiers ?? []).map((r) => r.ResourceArn),
179 });
180 }
181
182 if (request.method === "GET" && pathname === "/account-settings") {
183 const { AccountSettings } = yield* getAccountSettings();
184 return yield* HttpServerResponse.json({
185 status: AccountSettings?.GroupLifecycleEventsStatus,
186 });
187 }
188
189 if (request.method === "GET" && pathname === "/tag-sync-tasks") {
190 const { TagSyncTasks } = yield* listTagSyncTasks({});
191 return yield* HttpServerResponse.json({
192 count: (TagSyncTasks ?? []).length,
193 });
194 }
195
196 // Tag-sync is application-group-only: the typed rejections prove
197 // IAM + wiring for the start/get/cancel operations.
198 if (request.method === "POST" && pathname === "/start-tag-sync") {
199 return yield* HttpServerResponse.json(
200 yield* tagOf(
201 startTagSyncTask({ TagKey: "alchemy-rg-sync", TagValue: "on" }),
202 ),
203 );
204 }
205 if (request.method === "GET" && pathname === "/tag-sync-task") {
206 const taskArn = url.searchParams.get("arn") ?? "";
207 return yield* HttpServerResponse.json(
208 yield* tagOf(getTagSyncTask({ TaskArn: taskArn })),
209 );
210 }
211 if (request.method === "POST" && pathname === "/cancel-tag-sync") {
212 const { arn } = (yield* request.json) as { arn: string };
213 return yield* HttpServerResponse.json(
214 yield* tagOf(cancelTagSyncTask({ TaskArn: arn })),
215 );
216 }
217
218 return yield* HttpServerResponse.json(
219 { error: "Not found", method: request.method, pathname },
220 { status: 404 },
221 );
222 }).pipe(Effect.orDie),
223 };
224 }).pipe(
225 Effect.provide(
226 Layer.mergeAll(
227 Lambda.EventSource,
228 ResourceGroups.ListGroupResourcesHttp,
229 ResourceGroups.ListGroupingStatusesHttp,
230 ResourceGroups.GroupResourcesHttp,
231 ResourceGroups.UngroupResourcesHttp,
232 ResourceGroups.StartTagSyncTaskHttp,
233 ResourceGroups.SearchResourcesHttp,
234 ResourceGroups.GetAccountSettingsHttp,
235 ResourceGroups.ListTagSyncTasksHttp,
236 ResourceGroups.GetTagSyncTaskHttp,
237 ResourceGroups.CancelTagSyncTaskHttp,
238 ),
239 ),
240 ),
241);