| 1 | import * as AppConfig from "@/AWS/AppConfig"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as S3 from "@/AWS/S3"; |
| 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, "events-handler.ts"); |
| 13 | |
| 14 | export class AppConfigEventsTestFunction extends Lambda.Function<Lambda.Function>()( |
| 15 | "AppConfigEventsTestFunction", |
| 16 | ) {} |
| 17 | |
| 18 | export default AppConfigEventsTestFunction.make( |
| 19 | { |
| 20 | main, |
| 21 | functionUrl: true, |
| 22 | timeout: Duration.seconds(30), |
| 23 | }, |
| 24 | Effect.gen(function* () { |
| 25 | const app = yield* AppConfig.Application("EventsApp", {}); |
| 26 | const env = yield* AppConfig.Environment("EventsEnv", { |
| 27 | applicationId: app.applicationId, |
| 28 | }); |
| 29 | const profile = yield* AppConfig.ConfigurationProfile("EventsProfile", { |
| 30 | applicationId: app.applicationId, |
| 31 | locationUri: "hosted", |
| 32 | }); |
| 33 | yield* AppConfig.HostedConfigurationVersion("EventsV1", { |
| 34 | applicationId: app.applicationId, |
| 35 | configurationProfileId: profile.configurationProfileId, |
| 36 | content: JSON.stringify({ revision: 1 }), |
| 37 | contentType: "application/json", |
| 38 | }); |
| 39 | const strategy = yield* AppConfig.DeploymentStrategy("EventsAllAtOnce", { |
| 40 | deploymentDuration: 0, |
| 41 | growthFactor: 100, |
| 42 | finalBakeTime: 0, |
| 43 | replicateTo: "NONE", |
| 44 | }); |
| 45 | |
| 46 | // Deployment notifications arrive as separate async invocations that may |
| 47 | // land on a DIFFERENT warm instance than the one serving the test's |
| 48 | // polls, so instance memory cannot capture them. Persist each event to |
| 49 | // S3 under a deterministic key and read it back by key. |
| 50 | const bucket = yield* S3.Bucket("EventsBucket", { forceDestroy: true }); |
| 51 | const putObject = yield* S3.PutObject(bucket); |
| 52 | const getObject = yield* S3.GetObject(bucket); |
| 53 | |
| 54 | // Deployments are triggered at runtime (POST /deploy) so the extension |
| 55 | // association below exists before the first deployment starts. |
| 56 | const startDeployment = yield* AppConfig.StartDeployment( |
| 57 | app, |
| 58 | env, |
| 59 | profile, |
| 60 | strategy, |
| 61 | ); |
| 62 | |
| 63 | yield* AppConfig.consumeDeploymentEvents( |
| 64 | env, |
| 65 | { events: ["ON_DEPLOYMENT_START", "ON_DEPLOYMENT_COMPLETE"] }, |
| 66 | (events) => |
| 67 | events.pipe( |
| 68 | Stream.runForEach((event) => |
| 69 | putObject({ |
| 70 | Key: `events/${event.DeploymentNumber}-${event.Type}.json`, |
| 71 | Body: JSON.stringify(event), |
| 72 | ContentType: "application/json", |
| 73 | }).pipe(Effect.orDie), |
| 74 | ), |
| 75 | ), |
| 76 | ); |
| 77 | |
| 78 | return { |
| 79 | fetch: Effect.gen(function* () { |
| 80 | const request = yield* HttpServerRequest; |
| 81 | const url = new URL(request.originalUrl); |
| 82 | const pathname = url.pathname; |
| 83 | |
| 84 | if (request.method === "POST" && pathname === "/deploy") { |
| 85 | const body = (yield* request.json) as unknown as { |
| 86 | version: string; |
| 87 | }; |
| 88 | const started = yield* startDeployment({ |
| 89 | ConfigurationVersion: body.version, |
| 90 | }); |
| 91 | return yield* HttpServerResponse.json({ |
| 92 | deploymentNumber: started.DeploymentNumber, |
| 93 | state: started.State, |
| 94 | }); |
| 95 | } |
| 96 | |
| 97 | if (request.method === "GET" && pathname === "/event") { |
| 98 | const number = url.searchParams.get("number"); |
| 99 | const type = url.searchParams.get("type"); |
| 100 | return yield* getObject({ |
| 101 | Key: `events/${number}-${type}.json`, |
| 102 | }).pipe( |
| 103 | Effect.flatMap((result) => |
| 104 | Stream.mkString(Stream.decodeText(result.Body!)), |
| 105 | ), |
| 106 | Effect.flatMap((text) => |
| 107 | HttpServerResponse.json({ event: JSON.parse(text) }), |
| 108 | ), |
| 109 | // Notification not delivered yet — the test polls until it is. |
| 110 | Effect.catchTag("NoSuchKey", () => |
| 111 | HttpServerResponse.json({ event: null }, { status: 404 }), |
| 112 | ), |
| 113 | ); |
| 114 | } |
| 115 | |
| 116 | if (request.method === "GET" && pathname === "/ping") { |
| 117 | return yield* HttpServerResponse.json({ ok: true }); |
| 118 | } |
| 119 | |
| 120 | return yield* HttpServerResponse.json( |
| 121 | { error: "Not found", method: request.method, pathname }, |
| 122 | { status: 404 }, |
| 123 | ); |
| 124 | }).pipe(Effect.orDie), |
| 125 | }; |
| 126 | }).pipe( |
| 127 | Effect.provide( |
| 128 | Layer.mergeAll( |
| 129 | Lambda.AppConfigDeploymentEventSource, |
| 130 | AppConfig.StartDeploymentHttp, |
| 131 | S3.PutObjectHttp, |
| 132 | S3.GetObjectHttp, |
| 133 | ), |
| 134 | ), |
| 135 | ), |
| 136 | ); |