| 1 | import * as GreengrassV2 from "@/AWS/GreengrassV2"; |
| 2 | import { Thing } from "@/AWS/IoT"; |
| 3 | import * as Lambda from "@/AWS/Lambda"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | export const COMPONENT_NAME = "com.alchemy.test.GgBindings"; |
| 15 | export const COMPONENT_VERSION = "1.0.0"; |
| 16 | |
| 17 | const 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. |
| 33 | const MISSING_CORE = "alchemy-gg-bindings-missing-core"; |
| 34 | |
| 35 | export 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 | */ |
| 44 | const 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 | |
| 56 | export 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 | ); |