| 1 | import * as machines from "@distilled.cloud/fly-io/machines"; |
| 2 | import * as Fly from "@/Fly"; |
| 3 | import { sameStopConfig } from "@/Fly/replicas.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Deferred from "effect/Deferred"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import { createHash } from "node:crypto"; |
| 14 | import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts"; |
| 15 | import { |
| 16 | assertAppGone, |
| 17 | assertMachineGone, |
| 18 | assertOnlyMachine, |
| 19 | getText, |
| 20 | initialText, |
| 21 | startTraffic, |
| 22 | websiteChecks, |
| 23 | websiteServices, |
| 24 | } from "./fixtures/deployment.ts"; |
| 25 | |
| 26 | const { test } = Test.make({ providers: Fly.providers() }); |
| 27 | const logLevel = Effect.provideService( |
| 28 | MinimumLogLevel, |
| 29 | process.env.DEBUG ? "Debug" : "Info", |
| 30 | ); |
| 31 | |
| 32 | type ResponseRecord = { |
| 33 | event: string; |
| 34 | version: string; |
| 35 | machine: string; |
| 36 | managedTimeout: null; |
| 37 | payload?: string; |
| 38 | startedAt: number; |
| 39 | signaledAt: number; |
| 40 | completedAt: number; |
| 41 | signal: string; |
| 42 | activeAtSignal: number; |
| 43 | shutdownMs: number; |
| 44 | }; |
| 45 | |
| 46 | for (const policy of [ |
| 47 | { |
| 48 | old: "60 seconds", |
| 49 | next: "10 seconds", |
| 50 | oldMs: 60_000, |
| 51 | nextMs: 10_000, |
| 52 | delay: 12_000, |
| 53 | signal: "SIGTERM", |
| 54 | nextSignal: "SIGINT", |
| 55 | }, |
| 56 | { |
| 57 | old: "10 seconds", |
| 58 | next: "60 seconds", |
| 59 | oldMs: 10_000, |
| 60 | nextMs: 60_000, |
| 61 | delay: 3_000, |
| 62 | signal: "SIGINT", |
| 63 | nextSignal: "SIGTERM", |
| 64 | }, |
| 65 | ] as const) { |
| 66 | test.provider( |
| 67 | `R08/R02 SvelteKit external SSR: v1 to v2, old ${policy.old}/${policy.signal}, new ${policy.next}/${policy.nextSignal}, application-owned response drain`, |
| 68 | (stack) => |
| 69 | Effect.gen(function* () { |
| 70 | yield* stack.destroy(); |
| 71 | const fs = yield* FileSystem.FileSystem; |
| 72 | const path = yield* Path.Path; |
| 73 | const rootDir = yield* cloneFixture( |
| 74 | path.resolve( |
| 75 | import.meta.dirname, |
| 76 | "../../AWS/Website/fixtures/sveltekit-app", |
| 77 | ), |
| 78 | { |
| 79 | prefix: "alchemy-sveltekit-fly-", |
| 80 | tempRoot: path.resolve(import.meta.dirname, "../../../.tmp"), |
| 81 | entries: [".gitignore", "package.json", "src", "static"], |
| 82 | }, |
| 83 | ); |
| 84 | const route = path.join( |
| 85 | rootDir, |
| 86 | "src/routes/api/deployment/[operation]", |
| 87 | ); |
| 88 | yield* fs.makeDirectory(route, { recursive: true }); |
| 89 | for (const file of ["+server.ts", "state.ts", "version.ts"]) { |
| 90 | yield* fs.writeFileString( |
| 91 | path.join(route, file), |
| 92 | yield* fs.readFileString( |
| 93 | path.join( |
| 94 | import.meta.dirname, |
| 95 | "fixtures/sveltekit/src/routes/api/deployment/[operation]", |
| 96 | file, |
| 97 | ), |
| 98 | ), |
| 99 | ); |
| 100 | } |
| 101 | yield* fs.writeFileString( |
| 102 | path.join(rootDir, "src/hooks.server.ts"), |
| 103 | yield* fs.readFileString( |
| 104 | path.join( |
| 105 | import.meta.dirname, |
| 106 | "fixtures/sveltekit/src/hooks.server.ts", |
| 107 | ), |
| 108 | ), |
| 109 | ); |
| 110 | const pagePath = path.join(rootDir, "src/routes/+page.server.ts"); |
| 111 | const page = yield* fs.readFileString(pagePath); |
| 112 | const writeVersion = (version: string) => |
| 113 | Effect.gen(function* () { |
| 114 | yield* fs.writeFileString( |
| 115 | path.join(route, "version.ts"), |
| 116 | `export const version: string = ${JSON.stringify(version)};\n`, |
| 117 | ); |
| 118 | yield* fs.writeFileString( |
| 119 | pagePath, |
| 120 | page.replace( |
| 121 | "SVELTEKIT_AWS_PAGE_MARKER", |
| 122 | `SVELTEKIT_AWS_PAGE_MARKER ${version}`, |
| 123 | ), |
| 124 | ); |
| 125 | }); |
| 126 | const deploy = (next: boolean) => |
| 127 | stack.deploy( |
| 128 | Effect.gen(function* () { |
| 129 | const site = yield* Fly.Website.SvelteKit("Web", { |
| 130 | rootDir, |
| 131 | deploy: { strategy: "bluegreen", healthTimeout: "90 seconds" }, |
| 132 | shutdown: { |
| 133 | signal: next ? policy.nextSignal : policy.signal, |
| 134 | timeout: next ? policy.next : policy.old, |
| 135 | }, |
| 136 | checks: websiteChecks("/api/deployment/ready"), |
| 137 | services: websiteServices("/api/deployment/ready"), |
| 138 | env: { |
| 139 | WEBSITE_SHUTDOWN_MS: String( |
| 140 | next ? policy.nextMs : policy.oldMs, |
| 141 | ), |
| 142 | WEBSITE_AFTER_SIGNAL_MS: String(next ? 1000 : policy.delay), |
| 143 | }, |
| 144 | memo: { include: ["src/**", "static/**", "package.json"] }, |
| 145 | }); |
| 146 | return { site }; |
| 147 | }), |
| 148 | ); |
| 149 | let appName: string | undefined; |
| 150 | yield* Effect.gen(function* () { |
| 151 | yield* writeVersion("v1"); |
| 152 | const first = yield* deploy(false); |
| 153 | appName = first.site.app!.appName; |
| 154 | const oldId = first.site.service!.machineId; |
| 155 | const url = first.site.url!; |
| 156 | expect(url).toBe(`https://${appName}.fly.dev`); |
| 157 | expect(yield* initialText(`${url}/`)).toContain( |
| 158 | "SVELTEKIT_AWS_PAGE_MARKER v1", |
| 159 | ); |
| 160 | expect(yield* getText(`${url}/api/hello?echo=roundtrip`)).toContain( |
| 161 | "SVELTEKIT_AWS_API_MARKER", |
| 162 | ); |
| 163 | expect( |
| 164 | JSON.parse(yield* getText(`${url}/api/deployment/version`)), |
| 165 | ).toEqual({ version: "v1", machine: oldId, managedTimeout: null }); |
| 166 | const oldMachine = yield* machines.getMachine({ |
| 167 | app_name: appName, |
| 168 | machine_id: oldId, |
| 169 | }); |
| 170 | expect(oldMachine.config?.checks?.website?.path).toBe( |
| 171 | "/api/deployment/ready", |
| 172 | ); |
| 173 | expect(oldMachine.config?.services?.[0]?.checks?.[0]?.path).toBe( |
| 174 | "/api/deployment/ready", |
| 175 | ); |
| 176 | expect(oldMachine.config?.stop_config?.signal).toBe(policy.signal); |
| 177 | expect( |
| 178 | sameStopConfig(oldMachine.config?.stop_config, { |
| 179 | signal: policy.signal, |
| 180 | timeout: `${policy.oldMs}ms`, |
| 181 | }), |
| 182 | ).toBe(true); |
| 183 | expect( |
| 184 | oldMachine.config?.env?.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS, |
| 185 | ).toBeUndefined(); |
| 186 | |
| 187 | const slowStarted = yield* Deferred.make<void>(); |
| 188 | const streamStarted = yield* Deferred.make<void>(); |
| 189 | const slowRequest = ( |
| 190 | operation: string, |
| 191 | started: Deferred.Deferred<void>, |
| 192 | ) => |
| 193 | HttpClient.get(`${url}/api/deployment/${operation}`, { |
| 194 | headers: { connection: "close" }, |
| 195 | }).pipe( |
| 196 | Effect.tap((response) => |
| 197 | Effect.gen(function* () { |
| 198 | expect(response.status).toBe(200); |
| 199 | expect(response.headers["x-website-machine"]).toBe(oldId); |
| 200 | expect(response.headers["x-website-version"]).toBe("v1"); |
| 201 | yield* Deferred.succeed(started, undefined); |
| 202 | }), |
| 203 | ), |
| 204 | Effect.flatMap((response) => response.text), |
| 205 | Effect.timeout("10 minutes"), |
| 206 | ); |
| 207 | const slow = yield* slowRequest("slow", slowStarted).pipe( |
| 208 | Effect.forkScoped, |
| 209 | ); |
| 210 | const streamed = yield* slowRequest("stream", streamStarted).pipe( |
| 211 | Effect.forkScoped, |
| 212 | ); |
| 213 | yield* Effect.all( |
| 214 | [Deferred.await(slowStarted), Deferred.await(streamStarted)], |
| 215 | { concurrency: "unbounded" }, |
| 216 | ).pipe(Effect.timeout("20 seconds")); |
| 217 | const traffic = yield* startTraffic(`${url}/api/deployment/version`); |
| 218 | yield* traffic.waitFor((body) => JSON.parse(body).version === "v1"); |
| 219 | yield* writeVersion("v2"); |
| 220 | const second = yield* deploy(true); |
| 221 | const newId = second.site.service!.machineId; |
| 222 | expect(second.site.url).toBe(url); |
| 223 | expect(newId).not.toBe(oldId); |
| 224 | const slowResult = JSON.parse( |
| 225 | yield* Fiber.join(slow), |
| 226 | ) as ResponseRecord; |
| 227 | const streamBody = yield* Fiber.join(streamed); |
| 228 | const records = streamBody |
| 229 | .trim() |
| 230 | .split("\n") |
| 231 | .map((line) => JSON.parse(line) as ResponseRecord); |
| 232 | expect(records[0].event).toBe("started"); |
| 233 | expect( |
| 234 | records.filter((record) => record.event === "finished"), |
| 235 | ).toHaveLength(1); |
| 236 | expect(records[records.length - 1].event).toBe("finished"); |
| 237 | const completed = records[records.length - 1]; |
| 238 | const payload = records |
| 239 | .map((record) => record.payload ?? "") |
| 240 | .join(""); |
| 241 | const expected = "website-v1\n".repeat(8192); |
| 242 | const hashes = yield* Effect.sync(() => |
| 243 | [payload, expected].map((value) => |
| 244 | createHash("sha256").update(value).digest("hex"), |
| 245 | ), |
| 246 | ); |
| 247 | expect(payload.length).toBe(expected.length); |
| 248 | expect(hashes[0]).toBe(hashes[1]); |
| 249 | for (const result of [slowResult, completed]) { |
| 250 | expect(result.version).toBe("v1"); |
| 251 | expect(result.machine).toBe(oldId); |
| 252 | expect(result.managedTimeout).toBeNull(); |
| 253 | expect(result.signal).toBe(policy.signal); |
| 254 | expect(result.shutdownMs).toBe(policy.oldMs); |
| 255 | expect(result.activeAtSignal).toBe(2); |
| 256 | expect(result.signaledAt).toBeGreaterThan(result.startedAt); |
| 257 | expect( |
| 258 | result.completedAt - result.signaledAt, |
| 259 | ).toBeGreaterThanOrEqual(policy.delay); |
| 260 | expect(result.completedAt - result.signaledAt).toBeLessThan( |
| 261 | policy.oldMs, |
| 262 | ); |
| 263 | } |
| 264 | expect(yield* getText(`${url}/`)).toContain( |
| 265 | "SVELTEKIT_AWS_PAGE_MARKER v2", |
| 266 | ); |
| 267 | yield* traffic.waitFor((body) => JSON.parse(body).version === "v2"); |
| 268 | const samples = yield* traffic.finish; |
| 269 | for (const body of samples) { |
| 270 | const sample = JSON.parse(body); |
| 271 | expect(sample).toEqual({ |
| 272 | version: sample.version, |
| 273 | machine: sample.version === "v1" ? oldId : newId, |
| 274 | managedTimeout: null, |
| 275 | }); |
| 276 | expect(["v1", "v2"]).toContain(sample.version); |
| 277 | } |
| 278 | const newMachine = yield* machines.getMachine({ |
| 279 | app_name: appName, |
| 280 | machine_id: newId, |
| 281 | }); |
| 282 | expect(newMachine.config?.stop_config?.signal).toBe( |
| 283 | policy.nextSignal, |
| 284 | ); |
| 285 | expect( |
| 286 | sameStopConfig(newMachine.config?.stop_config, { |
| 287 | signal: policy.nextSignal, |
| 288 | timeout: `${policy.nextMs}ms`, |
| 289 | }), |
| 290 | ).toBe(true); |
| 291 | expect( |
| 292 | newMachine.config?.env?.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS, |
| 293 | ).toBeUndefined(); |
| 294 | yield* assertMachineGone(appName, oldId); |
| 295 | yield* assertOnlyMachine(appName, newId); |
| 296 | }).pipe( |
| 297 | Effect.scoped, |
| 298 | Effect.ensuring( |
| 299 | Effect.gen(function* () { |
| 300 | yield* stack.destroy(); |
| 301 | if (appName) yield* assertAppGone(appName); |
| 302 | }).pipe(Effect.orDie), |
| 303 | ), |
| 304 | ); |
| 305 | }).pipe(logLevel), |
| 306 | // Includes two SSR/image builds, real registry pushes, old-policy drain and cleanup. |
| 307 | { |
| 308 | tags: [ |
| 309 | "provider:fly", |
| 310 | "provider:fly:machine", |
| 311 | "provider:fly:website", |
| 312 | "live", |
| 313 | ], |
| 314 | timeout: 900_000, |
| 315 | }, |
| 316 | ); |
| 317 | } |