1import * as GreengrassV2 from "@/AWS/GreengrassV2";
2import { Thing } from "@/AWS/IoT";
3import * as Lambda from "@/AWS/Lambda";
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 const COMPONENT_NAME = "com.alchemy.test.GgBindings";
15export const COMPONENT_VERSION = "1.0.0";
16
17const recipe = JSON.stringify({
18 RecipeFormatVersion: "2020-01-25",
19 ComponentName: COMPONENT_NAME,
20 ComponentVersion: COMPONENT_VERSION,
21 ComponentDescription: "Alchemy GreengrassV2 bindings fixture component",
22 ComponentPublisher: "Alchemy",
23 Manifests: [
24 {
25 Platform: { os: "linux" },
26 Lifecycle: { run: "echo greengrass bindings fixture" },
27 },
28 ],
29});
30
31// A core device thing name that never exists — used to prove the
32// core-device bindings' IAM + request plumbing via typed not-found errors.
33const MISSING_CORE = "alchemy-gg-bindings-missing-core";
34
35export class GreengrassTestFunction extends Lambda.Function<Lambda.Function>()(
36 "GreengrassTestFunction",
37) {}
38
39/**
40 * Collapse a typed call into `{ ok: true }` or `{ ok: false, tag }`.
41 * Failures are logged (visible in CloudWatch) so unexpected tags are
42 * diagnosable without redeploying.
43 */
44const probe = <A, E extends { readonly _tag: string }>(
45 self: Effect.Effect<A, E>,
46) =>
47 self.pipe(
48 Effect.map(() => ({ ok: true as const })),
49 Effect.catch((error) =>
50 Effect.logWarning(`probe failed: ${String(error)}`).pipe(
51 Effect.map(() => ({ ok: false as const, tag: error._tag })),
52 ),
53 ),
54 );
55
56export default GreengrassTestFunction.make(
57 {
58 main,
59 functionUrl: true,
60 timeout: Duration.seconds(30),
61 },
62 Effect.gen(function* () {
63 // The component version + deployment the scoped bindings are bound to.
64 // The deployment targets an IoT thing with no live core device, so it
65 // stays ACTIVE in the cloud and never installs anything.
66 const component = yield* GreengrassV2.ComponentVersion("BindingComponent", {
67 recipe,
68 });
69 const core = yield* Thing("GgBindingCore", {});
70 const deployment = yield* GreengrassV2.Deployment("BindingRollout", {
71 targetArn: core.thingArn,
72 components: {
73 [COMPONENT_NAME]: { componentVersion: component.componentVersion },
74 },
75 });
76
77 // Event source: subscribe the host to Greengrass status events. The
78 // deploy proves the EventBridge rule + invoke permission wiring.
79 yield* GreengrassV2.consumeGreengrassEvents(
80 { kinds: ["deployment-status", "component-status"] },
81 (events) =>
82 Stream.runForEach(events, (event) =>
83 Effect.log(
84 `greengrass event: ${event["detail-type"]} on ${event.detail.coreDeviceThingName}`,
85 ),
86 ),
87 );
88
89 // Component-version-scoped bindings.
90 const getComponent = yield* GreengrassV2.GetComponent(component);
91 const describeComponent = yield* GreengrassV2.DescribeComponent(component);
92 const getArtifact =
93 yield* GreengrassV2.GetComponentVersionArtifact(component);
94
95 // Deployment-scoped bindings.
96 const getDeployment = yield* GreengrassV2.GetDeployment(deployment);
97 const cancelDeployment = yield* GreengrassV2.CancelDeployment(deployment);
98
99 // Account-level bindings.
100 const listComponents = yield* GreengrassV2.ListComponents();
101 const listComponentVersions = yield* GreengrassV2.ListComponentVersions();
102 const listDeployments = yield* GreengrassV2.ListDeployments();
103 const listCoreDevices = yield* GreengrassV2.ListCoreDevices();
104 const getCoreDevice = yield* GreengrassV2.GetCoreDevice();
105 const deleteCoreDevice = yield* GreengrassV2.DeleteCoreDevice();
106 const listInstalledComponents =
107 yield* GreengrassV2.ListInstalledComponents();
108 const listEffectiveDeployments =
109 yield* GreengrassV2.ListEffectiveDeployments();
110 const listClientDevices =
111 yield* GreengrassV2.ListClientDevicesAssociatedWithCoreDevice();
112 const associateClientDevices =
113 yield* GreengrassV2.BatchAssociateClientDeviceWithCoreDevice();
114 const disassociateClientDevices =
115 yield* GreengrassV2.BatchDisassociateClientDeviceFromCoreDevice();
116 const getConnectivityInfo = yield* GreengrassV2.GetConnectivityInfo();
117 const updateConnectivityInfo = yield* GreengrassV2.UpdateConnectivityInfo();
118 const resolveComponentCandidates =
119 yield* GreengrassV2.ResolveComponentCandidates();
120
121 const bound = {
122 getComponent,
123 describeComponent,
124 getArtifact,
125 getDeployment,
126 cancelDeployment,
127 listComponents,
128 listComponentVersions,
129 listDeployments,
130 listCoreDevices,
131 getCoreDevice,
132 deleteCoreDevice,
133 listInstalledComponents,
134 listEffectiveDeployments,
135 listClientDevices,
136 associateClientDevices,
137 disassociateClientDevices,
138 getConnectivityInfo,
139 updateConnectivityInfo,
140 resolveComponentCandidates,
141 };
142
143 return {
144 fetch: Effect.gen(function* () {
145 const request = yield* HttpServerRequest;
146 const url = new URL(request.originalUrl);
147 const pathname = url.pathname;
148
149 if (request.method === "GET" && pathname === "/bindings") {
150 return yield* HttpServerResponse.json({
151 bound: Object.keys(bound),
152 });
153 }
154
155 // Component-scoped read: the component version ARN is injected.
156 if (request.method === "GET" && pathname === "/component") {
157 const metadata = yield* describeComponent();
158 return yield* HttpServerResponse.json({
159 arn: metadata.arn,
160 componentName: metadata.componentName,
161 componentVersion: metadata.componentVersion,
162 state: metadata.status?.componentState,
163 });
164 }
165
166 // Fetch the raw recipe and prove it round-trips.
167 if (request.method === "GET" && pathname === "/recipe") {
168 const { recipe: bytes, recipeOutputFormat } = yield* getComponent({
169 recipeOutputFormat: "JSON",
170 });
171 const text = yield* Effect.sync(() =>
172 new TextDecoder().decode(bytes),
173 );
174 return yield* HttpServerResponse.json({
175 recipeOutputFormat,
176 hasName: text.includes(COMPONENT_NAME),
177 });
178 }
179
180 // The fixture component has no artifacts — the typed error proves
181 // IAM + request plumbing end-to-end.
182 if (request.method === "GET" && pathname === "/artifact") {
183 const result = yield* probe(
184 getArtifact({ artifactName: "missing.zip" }),
185 );
186 return yield* HttpServerResponse.json(result);
187 }
188
189 // Deployment-scoped read: the deployment id is injected.
190 if (request.method === "GET" && pathname === "/deployment") {
191 const detail = yield* getDeployment();
192 return yield* HttpServerResponse.json({
193 deploymentId: detail.deploymentId,
194 deploymentStatus: detail.deploymentStatus,
195 targetArn: detail.targetArn,
196 isLatestForTarget: detail.isLatestForTarget,
197 });
198 }
199
200 // Account-level list, must include the fixture's deployment.
201 if (request.method === "GET" && pathname === "/deployments") {
202 const { deployments } = yield* listDeployments({
203 historyFilter: "LATEST_ONLY",
204 });
205 return yield* HttpServerResponse.json({
206 ids: (deployments ?? []).flatMap((d) =>
207 d.deploymentId === undefined ? [] : [d.deploymentId],
208 ),
209 });
210 }
211
212 // Account-level list, must include the fixture's component.
213 if (request.method === "GET" && pathname === "/components") {
214 const { components } = yield* listComponents({ scope: "PRIVATE" });
215 return yield* HttpServerResponse.json({
216 names: (components ?? []).flatMap((c) =>
217 c.componentName === undefined ? [] : [c.componentName],
218 ),
219 });
220 }
221
222 // List the fixture component's versions via its base ARN (derived
223 // at runtime from the version ARN).
224 if (request.method === "GET" && pathname === "/component-versions") {
225 const metadata = yield* describeComponent();
226 const baseArn = (metadata.arn ?? "").split(":versions:")[0]!;
227 const { componentVersions } = yield* listComponentVersions({
228 arn: baseArn,
229 });
230 return yield* HttpServerResponse.json({
231 versions: (componentVersions ?? []).flatMap((v) =>
232 v.componentVersion === undefined ? [] : [v.componentVersion],
233 ),
234 });
235 }
236
237 // Account-level list of core devices (usually empty in CI).
238 if (request.method === "GET" && pathname === "/core-devices") {
239 const { coreDevices } = yield* listCoreDevices();
240 return yield* HttpServerResponse.json({
241 count: (coreDevices ?? []).length,
242 });
243 }
244
245 // Core-device bindings probed against a thing name that never
246 // exists: typed not-found (never AccessDenied) proves IAM works.
247 if (request.method === "GET" && pathname === "/core-device-probes") {
248 const req = { coreDeviceThingName: MISSING_CORE };
249 return yield* HttpServerResponse.json({
250 getCoreDevice: yield* probe(getCoreDevice(req)),
251 deleteCoreDevice: yield* probe(deleteCoreDevice(req)),
252 listInstalledComponents: yield* probe(listInstalledComponents(req)),
253 listEffectiveDeployments: yield* probe(
254 listEffectiveDeployments(req),
255 ),
256 listClientDevices: yield* probe(listClientDevices(req)),
257 associateClientDevices: yield* probe(
258 associateClientDevices({
259 ...req,
260 entries: [{ thingName: "alchemy-gg-bindings-client" }],
261 }),
262 ),
263 disassociateClientDevices: yield* probe(
264 disassociateClientDevices({
265 ...req,
266 entries: [{ thingName: "alchemy-gg-bindings-client" }],
267 }),
268 ),
269 });
270 }
271
272 // Real write+read on the fixture thing's connectivity info (the
273 // thing exists; no live core device is required).
274 if (request.method === "POST" && pathname === "/connectivity") {
275 const detail = yield* getDeployment();
276 const thingName = (detail.targetArn ?? "").split("/").pop()!;
277 yield* updateConnectivityInfo({
278 thingName,
279 connectivityInfo: [
280 { id: "fixture", hostAddress: "127.0.0.1", portNumber: 8883 },
281 ],
282 });
283 const { connectivityInfo } = yield* getConnectivityInfo({
284 thingName,
285 });
286 return yield* HttpServerResponse.json({
287 hostAddresses: (connectivityInfo ?? []).flatMap((info) =>
288 info.hostAddress === undefined ? [] : [info.hostAddress],
289 ),
290 });
291 }
292
293 // Dependency resolution against the fixture component.
294 if (request.method === "GET" && pathname === "/resolve") {
295 const result = yield* resolveComponentCandidates({
296 platform: {
297 attributes: { os: "linux", architecture: "amd64" },
298 },
299 componentCandidates: [
300 {
301 componentName: COMPONENT_NAME,
302 versionRequirements: { fixture: `=${COMPONENT_VERSION}` },
303 },
304 ],
305 }).pipe(
306 Effect.map((response) => ({
307 ok: true as const,
308 names: (response.resolvedComponentVersions ?? []).flatMap((v) =>
309 v.componentName === undefined ? [] : [v.componentName],
310 ),
311 })),
312 Effect.catch((error) =>
313 Effect.logWarning(`resolve failed: ${String(error)}`).pipe(
314 Effect.map(() => ({
315 ok: false as const,
316 names: [] as string[],
317 tag: error._tag,
318 })),
319 ),
320 ),
321 );
322 return yield* HttpServerResponse.json(result);
323 }
324
325 // Deployment-scoped write: cancel the fixture rollout (called last).
326 if (request.method === "POST" && pathname === "/cancel-deployment") {
327 const result = yield* probe(cancelDeployment());
328 return yield* HttpServerResponse.json(result);
329 }
330
331 return yield* HttpServerResponse.json(
332 { error: "Not found", method: request.method, pathname },
333 { status: 404 },
334 );
335 }).pipe(Effect.orDie),
336 };
337 }).pipe(
338 Effect.provide(
339 Layer.mergeAll(
340 Lambda.EventSource,
341 GreengrassV2.GetComponentHttp,
342 GreengrassV2.DescribeComponentHttp,
343 GreengrassV2.GetComponentVersionArtifactHttp,
344 GreengrassV2.GetDeploymentHttp,
345 GreengrassV2.CancelDeploymentHttp,
346 GreengrassV2.ListComponentsHttp,
347 GreengrassV2.ListComponentVersionsHttp,
348 GreengrassV2.ListDeploymentsHttp,
349 GreengrassV2.ListCoreDevicesHttp,
350 GreengrassV2.GetCoreDeviceHttp,
351 GreengrassV2.DeleteCoreDeviceHttp,
352 GreengrassV2.ListInstalledComponentsHttp,
353 GreengrassV2.ListEffectiveDeploymentsHttp,
354 GreengrassV2.ListClientDevicesAssociatedWithCoreDeviceHttp,
355 GreengrassV2.BatchAssociateClientDeviceWithCoreDeviceHttp,
356 GreengrassV2.BatchDisassociateClientDeviceFromCoreDeviceHttp,
357 GreengrassV2.GetConnectivityInfoHttp,
358 GreengrassV2.UpdateConnectivityInfoHttp,
359 GreengrassV2.ResolveComponentCandidatesHttp,
360 ),
361 ),
362 ),
363);