| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | |
| 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Alchemy from "alchemy"; |
| 5 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | |
| 10 | import * as RelayConfiguration from "../Config.ts"; |
| 11 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; |
| 12 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; |
| 13 | import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; |
| 14 | |
| 15 | const config = RelayConfiguration.RelayConfiguration.of({ |
| 16 | relayIssuer: "https://relay.example.test", |
| 17 | apns: { |
| 18 | environment: "sandbox", |
| 19 | teamId: "team-id", |
| 20 | keyId: "key-id", |
| 21 | privateKey: Redacted.make("private-key"), |
| 22 | bundleId: "com.t3tools.t3code.dev", |
| 23 | }, |
| 24 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), |
| 25 | clerkSecretKey: Redacted.make("clerk-secret"), |
| 26 | clerkPublishableKey: "pk_test_test", |
| 27 | clerkJwtAudience: "t3-code-relay", |
| 28 | cloudMintPrivateKey: Redacted.make("cloud-private-key"), |
| 29 | cloudMintPublicKey: "cloud-public-key", |
| 30 | managedEndpointBaseDomain: "t3code.test", |
| 31 | managedEndpointNamespace: "dev_julius", |
| 32 | }); |
| 33 | |
| 34 | interface TunnelCall { |
| 35 | readonly operation: "get" | "list" | "create" | "putConfiguration" | "getToken" | "delete"; |
| 36 | readonly input: unknown; |
| 37 | } |
| 38 | |
| 39 | interface DnsCall { |
| 40 | readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord"; |
| 41 | readonly input: unknown; |
| 42 | } |
| 43 | |
| 44 | interface AllocationCall { |
| 45 | readonly operation: |
| 46 | | "get" |
| 47 | | "reserve" |
| 48 | | "recordTunnel" |
| 49 | | "recordDns" |
| 50 | | "markReady" |
| 51 | | "claimRelease" |
| 52 | | "claimDeprovision" |
| 53 | | "remove" |
| 54 | | "removeClaimed"; |
| 55 | readonly input: unknown; |
| 56 | } |
| 57 | |
| 58 | function allocationKey(input: { readonly userId: string; readonly environmentId: string }) { |
| 59 | return `${input.userId}:${input.environmentId}`; |
| 60 | } |
| 61 | |
| 62 | function makeTunnelClient(calls: TunnelCall[] = []) { |
| 63 | return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 64 | get: (tunnelId) => |
| 65 | Effect.sync(() => { |
| 66 | calls.push({ operation: "get", input: tunnelId }); |
| 67 | return { |
| 68 | id: tunnelId, |
| 69 | name: "managed-tunnel", |
| 70 | status: "down", |
| 71 | connsInactiveAt: "2026-06-01T00:00:00.000Z", |
| 72 | }; |
| 73 | }), |
| 74 | list: (request) => |
| 75 | Effect.sync(() => { |
| 76 | calls.push({ operation: "list", input: request }); |
| 77 | return { result: [] }; |
| 78 | }), |
| 79 | create: (request) => |
| 80 | Effect.sync(() => { |
| 81 | calls.push({ operation: "create", input: request }); |
| 82 | return { id: "tunnel-id", name: request.name }; |
| 83 | }), |
| 84 | putConfiguration: (tunnelId, tunnelConfig) => |
| 85 | Effect.sync(() => { |
| 86 | calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); |
| 87 | }), |
| 88 | getToken: (tunnelId) => |
| 89 | Effect.sync(() => { |
| 90 | calls.push({ operation: "getToken", input: tunnelId }); |
| 91 | return "connector-token"; |
| 92 | }), |
| 93 | delete: (tunnelId) => |
| 94 | Effect.sync(() => { |
| 95 | calls.push({ operation: "delete", input: tunnelId }); |
| 96 | }), |
| 97 | }); |
| 98 | } |
| 99 | |
| 100 | function makePersistentTunnelClient(calls: TunnelCall[] = []) { |
| 101 | let tunnel: { readonly id: string; readonly name: string } | null = null; |
| 102 | return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 103 | get: (tunnelId) => |
| 104 | Effect.suspend(() => { |
| 105 | calls.push({ operation: "get", input: tunnelId }); |
| 106 | return tunnel === null |
| 107 | ? Effect.fail( |
| 108 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 109 | operation: "get", |
| 110 | tunnelId, |
| 111 | cause: { _tag: "NotFound" }, |
| 112 | }), |
| 113 | ) |
| 114 | : Effect.succeed({ |
| 115 | ...tunnel, |
| 116 | status: "down", |
| 117 | connsInactiveAt: "2026-06-01T00:00:00.000Z", |
| 118 | }); |
| 119 | }), |
| 120 | list: (request) => |
| 121 | Effect.sync(() => { |
| 122 | calls.push({ operation: "list", input: request }); |
| 123 | return { result: tunnel === null ? [] : [tunnel] }; |
| 124 | }), |
| 125 | create: (request) => |
| 126 | Effect.sync(() => { |
| 127 | calls.push({ operation: "create", input: request }); |
| 128 | tunnel = { id: "tunnel-id", name: request.name }; |
| 129 | return tunnel; |
| 130 | }), |
| 131 | putConfiguration: (tunnelId, tunnelConfig) => |
| 132 | Effect.sync(() => { |
| 133 | calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); |
| 134 | }), |
| 135 | getToken: (tunnelId) => |
| 136 | Effect.sync(() => { |
| 137 | calls.push({ operation: "getToken", input: tunnelId }); |
| 138 | return "connector-token"; |
| 139 | }), |
| 140 | delete: (tunnelId) => |
| 141 | Effect.sync(() => { |
| 142 | calls.push({ operation: "delete", input: tunnelId }); |
| 143 | tunnel = null; |
| 144 | }), |
| 145 | }); |
| 146 | } |
| 147 | |
| 148 | function makeDnsClient( |
| 149 | calls: DnsCall[] = [], |
| 150 | records: ReadonlyArray<{ readonly id: string }> = [], |
| 151 | ) { |
| 152 | let currentRecords = [...records]; |
| 153 | return ManagedEndpointProvider.ManagedEndpointDnsClient.of({ |
| 154 | listRecords: (hostname) => |
| 155 | Effect.sync(() => { |
| 156 | calls.push({ operation: "listRecords", input: hostname }); |
| 157 | return currentRecords; |
| 158 | }), |
| 159 | createRecord: (request) => |
| 160 | Effect.sync(() => { |
| 161 | calls.push({ operation: "createRecord", input: request }); |
| 162 | const record = { id: "created-record-id" }; |
| 163 | currentRecords = [record]; |
| 164 | return record; |
| 165 | }), |
| 166 | updateRecord: (dnsRecordId, request) => |
| 167 | Effect.gen(function* () { |
| 168 | calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); |
| 169 | if (!currentRecords.some((record) => record.id === dnsRecordId)) { |
| 170 | return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ |
| 171 | operation: "update-record", |
| 172 | hostname: request.name, |
| 173 | dnsRecordId, |
| 174 | cause: { _tag: "NotFound", dnsRecordId }, |
| 175 | }); |
| 176 | } |
| 177 | }), |
| 178 | deleteRecord: (dnsRecordId) => |
| 179 | Effect.sync(() => { |
| 180 | calls.push({ operation: "deleteRecord", input: dnsRecordId }); |
| 181 | currentRecords = currentRecords.filter((record) => record.id !== dnsRecordId); |
| 182 | }), |
| 183 | }); |
| 184 | } |
| 185 | |
| 186 | function makeAllocations(calls: AllocationCall[] = []) { |
| 187 | const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>(); |
| 188 | const recoveryEnabled = new Set<string>(); |
| 189 | let generation = 0; |
| 190 | const mutate = ( |
| 191 | key: string, |
| 192 | change: ( |
| 193 | allocation: ManagedEndpointAllocations.ManagedEndpointAllocation, |
| 194 | ) => ManagedEndpointAllocations.ManagedEndpointAllocation, |
| 195 | ) => { |
| 196 | const allocation = allocations.get(key); |
| 197 | if (allocation !== undefined) { |
| 198 | allocations.set(key, { |
| 199 | ...change(allocation), |
| 200 | generation: allocation.generation + 1, |
| 201 | updatedAt: `generation-${++generation}`, |
| 202 | }); |
| 203 | } |
| 204 | }; |
| 205 | return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 206 | getByTunnelName: () => Effect.die("unused getByTunnelName"), |
| 207 | get: (input) => |
| 208 | Effect.sync(() => { |
| 209 | calls.push({ operation: "get", input }); |
| 210 | return allocations.get(allocationKey(input)) ?? null; |
| 211 | }), |
| 212 | reserve: (input) => |
| 213 | Effect.sync(() => { |
| 214 | calls.push({ operation: "reserve", input }); |
| 215 | const allocation = allocations.get(allocationKey(input)) ?? { |
| 216 | ...input, |
| 217 | tunnelId: null, |
| 218 | dnsRecordId: null, |
| 219 | readyAt: null, |
| 220 | origin: null, |
| 221 | updatedAt: `generation-${++generation}`, |
| 222 | generation: 0, |
| 223 | tunnelReleasedAt: null, |
| 224 | }; |
| 225 | allocations.set(allocationKey(input), allocation); |
| 226 | return allocation; |
| 227 | }), |
| 228 | recordTunnel: (input) => |
| 229 | Effect.sync(() => { |
| 230 | calls.push({ operation: "recordTunnel", input }); |
| 231 | const current = allocations.get(allocationKey(input)); |
| 232 | if (current?.generation !== input.generation) { |
| 233 | return null; |
| 234 | } |
| 235 | mutate(allocationKey(input), (allocation) => ({ |
| 236 | ...allocation, |
| 237 | tunnelId: input.tunnelId, |
| 238 | readyAt: allocation.tunnelId === input.tunnelId ? allocation.readyAt : null, |
| 239 | })); |
| 240 | return allocations.get(allocationKey(input))?.generation ?? null; |
| 241 | }), |
| 242 | recordDns: (input) => |
| 243 | Effect.sync(() => { |
| 244 | calls.push({ operation: "recordDns", input }); |
| 245 | const current = allocations.get(allocationKey(input)); |
| 246 | if (current?.generation !== input.generation || current.tunnelId !== input.tunnelId) { |
| 247 | return null; |
| 248 | } |
| 249 | mutate(allocationKey(input), (allocation) => ({ |
| 250 | ...allocation, |
| 251 | dnsRecordId: input.dnsRecordId, |
| 252 | })); |
| 253 | return allocations.get(allocationKey(input))?.generation ?? null; |
| 254 | }), |
| 255 | markReady: (input) => |
| 256 | Effect.sync(() => { |
| 257 | calls.push({ operation: "markReady", input }); |
| 258 | const current = allocations.get(allocationKey(input)); |
| 259 | if (current?.generation !== input.generation || current.tunnelId !== input.tunnelId) { |
| 260 | return false; |
| 261 | } |
| 262 | mutate(allocationKey(input), (allocation) => ({ |
| 263 | ...allocation, |
| 264 | readyAt: "2026-06-02T00:00:00.000Z", |
| 265 | origin: input.origin, |
| 266 | })); |
| 267 | return true; |
| 268 | }), |
| 269 | enableRecovery: (input) => |
| 270 | Effect.sync(() => { |
| 271 | const allocation = allocations.get(allocationKey(input)); |
| 272 | if (allocation?.tunnelId !== input.tunnelId) { |
| 273 | return false; |
| 274 | } |
| 275 | recoveryEnabled.add(allocationKey(input)); |
| 276 | return true; |
| 277 | }), |
| 278 | listByTunnelNames: (tunnelNames) => |
| 279 | Effect.sync(() => |
| 280 | [...allocations.values()] |
| 281 | .filter((allocation) => tunnelNames.includes(allocation.tunnelName)) |
| 282 | .map((allocation) => ({ |
| 283 | ...allocation, |
| 284 | recoveryEnabled: recoveryEnabled.has(allocationKey(allocation)), |
| 285 | })), |
| 286 | ), |
| 287 | claimRelease: (input) => |
| 288 | Effect.sync(() => { |
| 289 | calls.push({ operation: "claimRelease", input }); |
| 290 | const allocation = allocations.get(allocationKey(input)); |
| 291 | if ( |
| 292 | allocation === undefined || |
| 293 | allocation.tunnelId !== input.tunnelId || |
| 294 | allocation.generation !== input.generation |
| 295 | ) { |
| 296 | return null; |
| 297 | } |
| 298 | mutate(allocationKey(input), (current) => current); |
| 299 | return allocations.get(allocationKey(input))?.generation ?? null; |
| 300 | }), |
| 301 | withClaimedTunnel: (input, effect) => |
| 302 | Effect.suspend(() => { |
| 303 | const current = allocations.get(allocationKey(input)); |
| 304 | return current?.tunnelId === input.tunnelId && current.generation === input.generation |
| 305 | ? Effect.asSome(effect) |
| 306 | : Effect.succeedNone; |
| 307 | }), |
| 308 | claimDeprovision: (input) => |
| 309 | Effect.sync(() => { |
| 310 | calls.push({ operation: "claimDeprovision", input }); |
| 311 | const allocation = allocations.get(allocationKey(input)); |
| 312 | if (allocation === undefined || allocation.generation !== input.generation) { |
| 313 | return null; |
| 314 | } |
| 315 | mutate(allocationKey(input), (current) => current); |
| 316 | return allocations.get(allocationKey(input))?.generation ?? null; |
| 317 | }), |
| 318 | remove: (input) => |
| 319 | Effect.sync(() => { |
| 320 | calls.push({ operation: "remove", input }); |
| 321 | allocations.delete(allocationKey(input)); |
| 322 | }), |
| 323 | removeClaimed: (input) => |
| 324 | Effect.sync(() => { |
| 325 | calls.push({ operation: "removeClaimed", input }); |
| 326 | const allocation = allocations.get(allocationKey(input)); |
| 327 | if (allocation === undefined || allocation.generation !== input.generation) { |
| 328 | return false; |
| 329 | } |
| 330 | allocations.delete(allocationKey(input)); |
| 331 | return true; |
| 332 | }), |
| 333 | }); |
| 334 | } |
| 335 | |
| 336 | function makeTunnelLimits( |
| 337 | calls: Array<{ readonly userId: string; readonly environmentId: string }> = [], |
| 338 | result: ManagedTunnelLimits.ManagedTunnelLimitExceeded | null = null, |
| 339 | ) { |
| 340 | return ManagedTunnelLimits.ManagedTunnelLimits.of({ |
| 341 | ensureCapacity: (input) => |
| 342 | Effect.suspend(() => { |
| 343 | calls.push(input); |
| 344 | return result === null ? Effect.void : Effect.fail(result); |
| 345 | }), |
| 346 | }); |
| 347 | } |
| 348 | |
| 349 | function layerProvider( |
| 350 | tunnelClient = makeTunnelClient(), |
| 351 | dnsClient = makeDnsClient(), |
| 352 | allocations = makeAllocations(), |
| 353 | tunnelLimits = makeTunnelLimits(), |
| 354 | ) { |
| 355 | return ManagedEndpointProvider.layer.pipe( |
| 356 | Layer.provideMerge(NodeServices.layer), |
| 357 | Layer.provide(RelayConfiguration.layer(config)), |
| 358 | Layer.provide(ManagedEndpointProvider.layerTunnelClient(tunnelClient)), |
| 359 | Layer.provide(ManagedEndpointProvider.layerDnsClient(dnsClient)), |
| 360 | Layer.provide( |
| 361 | Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations), |
| 362 | ), |
| 363 | Layer.provide(Layer.succeed(ManagedTunnelLimits.ManagedTunnelLimits, tunnelLimits)), |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | // First 16 hex chars of SHA-256(`dev_julius:${userId}:${environmentId}`), pinned so a |
| 368 | // change to the endpoint naming scheme fails here instead of silently matching. |
| 369 | const MANAGED_ENDPOINT_HASHES: Record<string, string> = { |
| 370 | "user_ABC:env_ABC": "d101ac68108a423e", |
| 371 | "user_ABC:env_shared": "d7e6356aaf8863aa", |
| 372 | "user_DEF:env_shared": "a2fc84ac1b8c1c35", |
| 373 | }; |
| 374 | |
| 375 | function expectedManagedEndpointHash(environmentId: string, userId: string): string { |
| 376 | const hash = MANAGED_ENDPOINT_HASHES[`${userId}:${environmentId}`]; |
| 377 | if (hash === undefined) throw new Error(`No pinned hash for ${userId}:${environmentId}`); |
| 378 | return hash; |
| 379 | } |
| 380 | |
| 381 | function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string { |
| 382 | return `dev-julius-${expectedManagedEndpointHash(environmentId, userId)}.t3code.test`; |
| 383 | } |
| 384 | |
| 385 | function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string { |
| 386 | return `t3coderelay-managedendpoint-dev-julius-${expectedManagedEndpointHash(environmentId, userId)}`; |
| 387 | } |
| 388 | |
| 389 | describe("ManagedEndpointProvider", () => { |
| 390 | it.effect("does not require the deployment RuntimeContext when building the Worker layer", () => { |
| 391 | const tunnelClient = { |
| 392 | get: () => Effect.succeed({ id: "tunnel-id", name: "managed-tunnel" }), |
| 393 | list: () => Effect.succeed({ result: [] }), |
| 394 | create: (request: { readonly name: string }) => |
| 395 | Effect.succeed({ id: "tunnel-id", name: request.name }), |
| 396 | putConfiguration: () => Effect.void, |
| 397 | getToken: () => Effect.succeed("connector-token"), |
| 398 | delete: () => Effect.void, |
| 399 | } as unknown as Cloudflare.Tunnel.ReadWriteTunnelClient; |
| 400 | const dnsClient = { |
| 401 | listDnsRecords: () => Effect.succeed({ result: [] }), |
| 402 | createDnsRecord: () => Effect.succeed({ id: "dns-record-id" }), |
| 403 | updateDnsRecord: () => Effect.void, |
| 404 | deleteDnsRecord: () => Effect.void, |
| 405 | } as unknown as Cloudflare.DNS.ReadWriteDnsClient; |
| 406 | const runtimeContext = {} as Alchemy.BaseRuntimeContext; |
| 407 | const layer = ManagedEndpointProvider.layerCloudflareBindings( |
| 408 | tunnelClient, |
| 409 | dnsClient, |
| 410 | runtimeContext, |
| 411 | ).pipe( |
| 412 | Layer.provideMerge(NodeServices.layer), |
| 413 | Layer.provide(RelayConfiguration.layer(config)), |
| 414 | Layer.provide( |
| 415 | Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, makeAllocations()), |
| 416 | ), |
| 417 | Layer.provide(Layer.succeed(ManagedTunnelLimits.ManagedTunnelLimits, makeTunnelLimits())), |
| 418 | ); |
| 419 | |
| 420 | return Effect.gen(function* () { |
| 421 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 422 | const result = yield* provider.provision({ |
| 423 | userId: "user_ABC", |
| 424 | environmentId: "env_ABC", |
| 425 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 426 | }); |
| 427 | |
| 428 | expect(result.runtime.connectorToken).toBe("connector-token"); |
| 429 | }).pipe(Effect.provide(layer)); |
| 430 | }); |
| 431 | |
| 432 | it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { |
| 433 | const tunnelCalls: TunnelCall[] = []; |
| 434 | const dnsCalls: DnsCall[] = []; |
| 435 | const allocationCalls: AllocationCall[] = []; |
| 436 | |
| 437 | return Effect.gen(function* () { |
| 438 | const hostname = expectedManagedHostname("env_ABC"); |
| 439 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 440 | const result = yield* provider.provision({ |
| 441 | userId: "user_ABC", |
| 442 | environmentId: "env_ABC", |
| 443 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 444 | }); |
| 445 | |
| 446 | expect(result).toEqual({ |
| 447 | endpoint: { |
| 448 | httpBaseUrl: `https://${hostname}/`, |
| 449 | wsBaseUrl: `wss://${hostname}/ws`, |
| 450 | providerKind: "cloudflare_tunnel", |
| 451 | }, |
| 452 | runtime: { |
| 453 | providerKind: "cloudflare_tunnel", |
| 454 | connectorToken: "connector-token", |
| 455 | tunnelId: "tunnel-id", |
| 456 | tunnelName: expectedManagedTunnelName("env_ABC"), |
| 457 | }, |
| 458 | }); |
| 459 | expect(dnsCalls).toEqual([ |
| 460 | { operation: "listRecords", input: hostname }, |
| 461 | { |
| 462 | operation: "createRecord", |
| 463 | input: { |
| 464 | type: "CNAME", |
| 465 | name: hostname, |
| 466 | content: "tunnel-id.cfargotunnel.com", |
| 467 | ttl: 1, |
| 468 | proxied: true, |
| 469 | }, |
| 470 | }, |
| 471 | ]); |
| 472 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ |
| 473 | "list", |
| 474 | "create", |
| 475 | "putConfiguration", |
| 476 | "getToken", |
| 477 | ]); |
| 478 | expect(tunnelCalls[2]?.input).toMatchObject({ |
| 479 | tunnelConfig: { |
| 480 | ingress: [ |
| 481 | { |
| 482 | hostname, |
| 483 | service: "http://127.0.0.1:3773", |
| 484 | }, |
| 485 | { service: "http_status:404" }, |
| 486 | ], |
| 487 | }, |
| 488 | }); |
| 489 | expect(tunnelCalls[0]?.input).toEqual({ |
| 490 | name: expectedManagedTunnelName("env_ABC"), |
| 491 | isDeleted: false, |
| 492 | }); |
| 493 | expect(allocationCalls.map((call) => call.operation)).toEqual([ |
| 494 | "reserve", |
| 495 | "recordTunnel", |
| 496 | "recordDns", |
| 497 | "markReady", |
| 498 | ]); |
| 499 | }).pipe( |
| 500 | Effect.provide( |
| 501 | layerProvider( |
| 502 | makeTunnelClient(tunnelCalls), |
| 503 | makeDnsClient(dnsCalls), |
| 504 | makeAllocations(allocationCalls), |
| 505 | ), |
| 506 | ), |
| 507 | ); |
| 508 | }); |
| 509 | |
| 510 | it.effect("checks the managed tunnel limit before reserving an allocation", () => { |
| 511 | const limitCalls: Array<{ readonly userId: string; readonly environmentId: string }> = []; |
| 512 | |
| 513 | return Effect.gen(function* () { |
| 514 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 515 | yield* provider.provision({ |
| 516 | userId: "user_ABC", |
| 517 | environmentId: "env_ABC", |
| 518 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 519 | }); |
| 520 | |
| 521 | expect(limitCalls).toEqual([{ userId: "user_ABC", environmentId: "env_ABC" }]); |
| 522 | }).pipe( |
| 523 | Effect.provide( |
| 524 | layerProvider( |
| 525 | makeTunnelClient(), |
| 526 | makeDnsClient(), |
| 527 | makeAllocations(), |
| 528 | makeTunnelLimits(limitCalls), |
| 529 | ), |
| 530 | ), |
| 531 | ); |
| 532 | }); |
| 533 | |
| 534 | it.effect("refuses to provision past the managed tunnel limit without side effects", () => { |
| 535 | const tunnelCalls: TunnelCall[] = []; |
| 536 | const dnsCalls: DnsCall[] = []; |
| 537 | const allocationCalls: AllocationCall[] = []; |
| 538 | const exceeded = new ManagedTunnelLimits.ManagedTunnelLimitExceeded({ |
| 539 | userId: "user_ABC", |
| 540 | environmentId: "env_ABC", |
| 541 | maxTunnels: 10, |
| 542 | activeTunnels: 10, |
| 543 | }); |
| 544 | |
| 545 | return Effect.gen(function* () { |
| 546 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 547 | const error = yield* Effect.flip( |
| 548 | provider.provision({ |
| 549 | userId: "user_ABC", |
| 550 | environmentId: "env_ABC", |
| 551 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 552 | }), |
| 553 | ); |
| 554 | |
| 555 | expect(error).toBe(exceeded); |
| 556 | expect(tunnelCalls).toEqual([]); |
| 557 | expect(dnsCalls).toEqual([]); |
| 558 | expect(allocationCalls).toEqual([]); |
| 559 | }).pipe( |
| 560 | Effect.provide( |
| 561 | layerProvider( |
| 562 | makeTunnelClient(tunnelCalls), |
| 563 | makeDnsClient(dnsCalls), |
| 564 | makeAllocations(allocationCalls), |
| 565 | makeTunnelLimits([], exceeded), |
| 566 | ), |
| 567 | ), |
| 568 | ); |
| 569 | }); |
| 570 | |
| 571 | it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => { |
| 572 | const tunnelCalls: TunnelCall[] = []; |
| 573 | |
| 574 | return Effect.gen(function* () { |
| 575 | const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); |
| 576 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 577 | yield* provider.provision({ |
| 578 | userId: "user_ABC", |
| 579 | environmentId, |
| 580 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 581 | }); |
| 582 | |
| 583 | const requestedName = ( |
| 584 | tunnelCalls.find((call) => call.operation === "list")?.input as |
| 585 | | { readonly name?: string } |
| 586 | | undefined |
| 587 | )?.name; |
| 588 | expect(requestedName).toMatch(/^t3coderelay-managedendpoint-dev-julius-[a-f0-9]{16}$/); |
| 589 | const configBody = ( |
| 590 | tunnelCalls.find((call) => call.operation === "putConfiguration")?.input as |
| 591 | | { readonly tunnelConfig?: unknown } |
| 592 | | undefined |
| 593 | )?.tunnelConfig; |
| 594 | expect(configBody).toMatchObject({ |
| 595 | ingress: [ |
| 596 | { |
| 597 | hostname: expect.stringMatching(/^dev-julius-[a-f0-9]{16}\.t3code\.test$/), |
| 598 | }, |
| 599 | { service: "http_status:404" }, |
| 600 | ], |
| 601 | }); |
| 602 | const hostname = ( |
| 603 | configBody as |
| 604 | | { |
| 605 | readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; |
| 606 | } |
| 607 | | undefined |
| 608 | )?.ingress?.[0]?.hostname; |
| 609 | expect(typeof hostname === "string" ? hostname.split(".")[0]?.length : 0).toBeLessThanOrEqual( |
| 610 | 63, |
| 611 | ); |
| 612 | expect(tunnelCalls.find((call) => call.operation === "create")?.input).toMatchObject({ |
| 613 | name: requestedName, |
| 614 | configSrc: "cloudflare", |
| 615 | }); |
| 616 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(tunnelCalls)))); |
| 617 | }); |
| 618 | |
| 619 | it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { |
| 620 | const tunnelCalls: TunnelCall[] = []; |
| 621 | |
| 622 | return Effect.gen(function* () { |
| 623 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 624 | yield* provider.provision({ |
| 625 | userId: "user_ABC", |
| 626 | environmentId: "env-ipv6", |
| 627 | origin: { localHttpHost: "::1", localHttpPort: 3773 }, |
| 628 | }); |
| 629 | |
| 630 | expect( |
| 631 | tunnelCalls.find((call) => call.operation === "putConfiguration")?.input, |
| 632 | ).toMatchObject({ |
| 633 | tunnelConfig: { |
| 634 | ingress: [ |
| 635 | { |
| 636 | service: "http://[::1]:3773", |
| 637 | }, |
| 638 | { service: "http_status:404" }, |
| 639 | ], |
| 640 | }, |
| 641 | }); |
| 642 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(tunnelCalls)))); |
| 643 | }); |
| 644 | |
| 645 | it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { |
| 646 | const dnsCalls: DnsCall[] = []; |
| 647 | |
| 648 | return Effect.gen(function* () { |
| 649 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 650 | const result = yield* Effect.result( |
| 651 | provider.provision({ |
| 652 | userId: "user_ABC", |
| 653 | environmentId: "env_ABC", |
| 654 | origin: { localHttpHost: "192.168.1.10", localHttpPort: 3773 }, |
| 655 | }), |
| 656 | ); |
| 657 | |
| 658 | expect(dnsCalls).toHaveLength(0); |
| 659 | expect(result._tag).toBe("Failure"); |
| 660 | if (result._tag === "Failure") { |
| 661 | expect(result.failure).toMatchObject({ |
| 662 | _tag: "ManagedEndpointOriginNotAllowed", |
| 663 | userId: "user_ABC", |
| 664 | environmentId: "env_ABC", |
| 665 | host: "192.168.1.10", |
| 666 | port: 3773, |
| 667 | }); |
| 668 | } |
| 669 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(), makeDnsClient(dnsCalls)))); |
| 670 | }); |
| 671 | |
| 672 | it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { |
| 673 | const dnsCalls: DnsCall[] = []; |
| 674 | |
| 675 | return Effect.gen(function* () { |
| 676 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 677 | const result = yield* Effect.result( |
| 678 | provider.provision({ |
| 679 | userId: "user_ABC", |
| 680 | environmentId: "env_ABC", |
| 681 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 65_536 }, |
| 682 | }), |
| 683 | ); |
| 684 | |
| 685 | expect(dnsCalls).toHaveLength(0); |
| 686 | expect(result._tag).toBe("Failure"); |
| 687 | if (result._tag === "Failure") { |
| 688 | expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); |
| 689 | } |
| 690 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(), makeDnsClient(dnsCalls)))); |
| 691 | }); |
| 692 | |
| 693 | it.effect("reconciles an existing same-host DNS record through the DNS client", () => { |
| 694 | const dnsCalls: DnsCall[] = []; |
| 695 | return Effect.gen(function* () { |
| 696 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 697 | yield* provider.provision({ |
| 698 | userId: "user_ABC", |
| 699 | environmentId: "env_ABC", |
| 700 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 701 | }); |
| 702 | |
| 703 | expect(dnsCalls.map((call) => call.operation)).toEqual(["listRecords", "updateRecord"]); |
| 704 | expect(dnsCalls[1]?.input).toMatchObject({ dnsRecordId: "existing-record-id" }); |
| 705 | }).pipe( |
| 706 | Effect.provide( |
| 707 | layerProvider(makeTunnelClient(), makeDnsClient(dnsCalls, [{ id: "existing-record-id" }])), |
| 708 | ), |
| 709 | ); |
| 710 | }); |
| 711 | |
| 712 | it.effect("reuses checkpointed resources when provisioning is retried", () => { |
| 713 | const tunnelCalls: TunnelCall[] = []; |
| 714 | const dnsCalls: DnsCall[] = []; |
| 715 | const allocationCalls: AllocationCall[] = []; |
| 716 | const layer = layerProvider( |
| 717 | makePersistentTunnelClient(tunnelCalls), |
| 718 | makeDnsClient(dnsCalls), |
| 719 | makeAllocations(allocationCalls), |
| 720 | ); |
| 721 | |
| 722 | return Effect.gen(function* () { |
| 723 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 724 | const request = { |
| 725 | userId: "user_ABC", |
| 726 | environmentId: "env_ABC", |
| 727 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 728 | } as const; |
| 729 | yield* provider.provision(request); |
| 730 | yield* provider.provision(request); |
| 731 | |
| 732 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ |
| 733 | "list", |
| 734 | "create", |
| 735 | "putConfiguration", |
| 736 | "getToken", |
| 737 | "list", |
| 738 | "putConfiguration", |
| 739 | "getToken", |
| 740 | ]); |
| 741 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 742 | "listRecords", |
| 743 | "createRecord", |
| 744 | "updateRecord", |
| 745 | ]); |
| 746 | expect(allocationCalls.map((call) => call.operation)).toEqual([ |
| 747 | "reserve", |
| 748 | "recordTunnel", |
| 749 | "recordDns", |
| 750 | "markReady", |
| 751 | "reserve", |
| 752 | "recordTunnel", |
| 753 | "recordDns", |
| 754 | "markReady", |
| 755 | ]); |
| 756 | }).pipe(Effect.provide(layer)); |
| 757 | }); |
| 758 | |
| 759 | it.effect("recreates a checkpointed DNS record when it was removed externally", () => { |
| 760 | const dnsCalls: DnsCall[] = []; |
| 761 | const allocationCalls: AllocationCall[] = []; |
| 762 | const dnsClient = makeDnsClient(dnsCalls); |
| 763 | const layer = layerProvider( |
| 764 | makePersistentTunnelClient(), |
| 765 | dnsClient, |
| 766 | makeAllocations(allocationCalls), |
| 767 | ); |
| 768 | |
| 769 | return Effect.gen(function* () { |
| 770 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 771 | const request = { |
| 772 | userId: "user_ABC", |
| 773 | environmentId: "env_ABC", |
| 774 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 775 | } as const; |
| 776 | yield* provider.provision(request); |
| 777 | yield* dnsClient.deleteRecord("created-record-id"); |
| 778 | yield* provider.provision(request); |
| 779 | |
| 780 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 781 | "listRecords", |
| 782 | "createRecord", |
| 783 | "deleteRecord", |
| 784 | "updateRecord", |
| 785 | "listRecords", |
| 786 | "createRecord", |
| 787 | ]); |
| 788 | }).pipe(Effect.provide(layer)); |
| 789 | }); |
| 790 | |
| 791 | it.effect("does not hide non-not-found checkpoint update failures", () => { |
| 792 | const dnsCalls: DnsCall[] = []; |
| 793 | const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ |
| 794 | operation: "update-record", |
| 795 | dnsRecordId: "created-record-id", |
| 796 | cause: new Error("Cloudflare DNS unavailable"), |
| 797 | }); |
| 798 | let records: ReadonlyArray<{ readonly id: string }> = []; |
| 799 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ |
| 800 | listRecords: (hostname) => |
| 801 | Effect.sync(() => { |
| 802 | dnsCalls.push({ operation: "listRecords", input: hostname }); |
| 803 | return records; |
| 804 | }), |
| 805 | createRecord: (request) => |
| 806 | Effect.sync(() => { |
| 807 | dnsCalls.push({ operation: "createRecord", input: request }); |
| 808 | const record = { id: "created-record-id" }; |
| 809 | records = [record]; |
| 810 | return record; |
| 811 | }), |
| 812 | updateRecord: (dnsRecordId, request) => |
| 813 | Effect.sync(() => { |
| 814 | dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); |
| 815 | }).pipe(Effect.andThen(Effect.fail(failure))), |
| 816 | deleteRecord: () => Effect.void, |
| 817 | }); |
| 818 | const allocations = makeAllocations(); |
| 819 | const layer = layerProvider(makePersistentTunnelClient(), dnsClient, allocations); |
| 820 | |
| 821 | return Effect.gen(function* () { |
| 822 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 823 | const request = { |
| 824 | userId: "user_ABC", |
| 825 | environmentId: "env_ABC", |
| 826 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 827 | } as const; |
| 828 | yield* provider.provision(request); |
| 829 | const error = yield* Effect.flip(provider.provision(request)); |
| 830 | |
| 831 | expect(error).toMatchObject({ |
| 832 | _tag: "ManagedEndpointProvisioningFailed", |
| 833 | stage: "ensure-dns-record", |
| 834 | userId: "user_ABC", |
| 835 | environmentId: "env_ABC", |
| 836 | }); |
| 837 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 838 | "listRecords", |
| 839 | "createRecord", |
| 840 | "updateRecord", |
| 841 | ]); |
| 842 | expect(yield* allocations.get(request)).toMatchObject({ |
| 843 | tunnelId: "tunnel-id", |
| 844 | readyAt: "2026-06-02T00:00:00.000Z", |
| 845 | }); |
| 846 | }).pipe(Effect.provide(layer)); |
| 847 | }); |
| 848 | |
| 849 | it.effect( |
| 850 | "deprovisions checkpointed DNS and tunnel resources before removing the allocation", |
| 851 | () => { |
| 852 | const tunnelCalls: TunnelCall[] = []; |
| 853 | const dnsCalls: DnsCall[] = []; |
| 854 | const allocationCalls: AllocationCall[] = []; |
| 855 | const layer = layerProvider( |
| 856 | makePersistentTunnelClient(tunnelCalls), |
| 857 | makeDnsClient(dnsCalls), |
| 858 | makeAllocations(allocationCalls), |
| 859 | ); |
| 860 | |
| 861 | return Effect.gen(function* () { |
| 862 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 863 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 864 | yield* provider.provision({ |
| 865 | ...key, |
| 866 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 867 | }); |
| 868 | yield* provider.deprovision(key); |
| 869 | |
| 870 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 871 | "listRecords", |
| 872 | "createRecord", |
| 873 | "deleteRecord", |
| 874 | ]); |
| 875 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ |
| 876 | "list", |
| 877 | "create", |
| 878 | "putConfiguration", |
| 879 | "getToken", |
| 880 | "delete", |
| 881 | ]); |
| 882 | expect(allocationCalls.map((call) => call.operation)).toEqual([ |
| 883 | "reserve", |
| 884 | "recordTunnel", |
| 885 | "recordDns", |
| 886 | "markReady", |
| 887 | "get", |
| 888 | "claimDeprovision", |
| 889 | "removeClaimed", |
| 890 | ]); |
| 891 | }).pipe(Effect.provide(layer)); |
| 892 | }, |
| 893 | ); |
| 894 | |
| 895 | it.effect("does not deprovision an allocation superseded by a concurrent relink", () => { |
| 896 | const tunnelCalls: TunnelCall[] = []; |
| 897 | const dnsCalls: DnsCall[] = []; |
| 898 | const allocationCalls: AllocationCall[] = []; |
| 899 | const layer = layerProvider( |
| 900 | makePersistentTunnelClient(tunnelCalls), |
| 901 | makeDnsClient(dnsCalls), |
| 902 | makeAllocations(allocationCalls), |
| 903 | ); |
| 904 | |
| 905 | return Effect.gen(function* () { |
| 906 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 907 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 908 | const request = { |
| 909 | ...key, |
| 910 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 911 | } as const; |
| 912 | yield* provider.provision(request); |
| 913 | const unlinkTarget = yield* provider.prepareDeprovision(key); |
| 914 | expect(unlinkTarget).not.toBeNull(); |
| 915 | if (unlinkTarget === null) { |
| 916 | return; |
| 917 | } |
| 918 | |
| 919 | // A relink refreshes the allocation generation after unlink captured its |
| 920 | // target but before unlink begins external teardown. |
| 921 | yield* provider.provision(request); |
| 922 | const tunnelCallCount = tunnelCalls.length; |
| 923 | const dnsCallCount = dnsCalls.length; |
| 924 | const allocationCallCount = allocationCalls.length; |
| 925 | |
| 926 | yield* provider.deprovision({ ...key, target: unlinkTarget }); |
| 927 | |
| 928 | expect(tunnelCalls).toHaveLength(tunnelCallCount); |
| 929 | expect(dnsCalls).toHaveLength(dnsCallCount); |
| 930 | expect(allocationCalls.slice(allocationCallCount).map((call) => call.operation)).toEqual([ |
| 931 | "claimDeprovision", |
| 932 | ]); |
| 933 | }).pipe(Effect.provide(layer)); |
| 934 | }); |
| 935 | |
| 936 | it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => { |
| 937 | const tunnelCalls: TunnelCall[] = []; |
| 938 | const dnsCalls: DnsCall[] = []; |
| 939 | const allocationCalls: AllocationCall[] = []; |
| 940 | const layer = layerProvider( |
| 941 | makePersistentTunnelClient(tunnelCalls), |
| 942 | makeDnsClient(dnsCalls), |
| 943 | makeAllocations(allocationCalls), |
| 944 | ); |
| 945 | |
| 946 | return Effect.gen(function* () { |
| 947 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 948 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 949 | const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; |
| 950 | const first = yield* provider.provision({ ...key, origin }); |
| 951 | const released = yield* provider.release(key); |
| 952 | const second = yield* provider.provision({ ...key, origin }); |
| 953 | |
| 954 | expect(released).toBe(true); |
| 955 | expect(second.endpoint).toEqual(first.endpoint); |
| 956 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ |
| 957 | // first provision |
| 958 | "list", |
| 959 | "create", |
| 960 | "putConfiguration", |
| 961 | "getToken", |
| 962 | // release deletes only the tunnel... |
| 963 | "delete", |
| 964 | // ...and the next provision recreates it under the same name |
| 965 | "list", |
| 966 | "create", |
| 967 | "putConfiguration", |
| 968 | "getToken", |
| 969 | ]); |
| 970 | // The DNS record survives the release and is repointed, never deleted. |
| 971 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 972 | "listRecords", |
| 973 | "createRecord", |
| 974 | "updateRecord", |
| 975 | ]); |
| 976 | expect(allocationCalls.map((call) => call.operation)).not.toContain("remove"); |
| 977 | // An ordinary release, such as a host shutting down, must not tell the |
| 978 | // user to update: that host gets a new tunnel when it starts again. |
| 979 | const claims = allocationCalls.filter((call) => call.operation === "claimRelease"); |
| 980 | expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual( |
| 981 | [undefined, undefined], |
| 982 | ); |
| 983 | }).pipe(Effect.provide(layer)); |
| 984 | }); |
| 985 | |
| 986 | it.effect("marks the release on the claim that deletes the tunnel when asked", () => { |
| 987 | const allocationCalls: AllocationCall[] = []; |
| 988 | const layer = layerProvider( |
| 989 | makePersistentTunnelClient(), |
| 990 | makeDnsClient(), |
| 991 | makeAllocations(allocationCalls), |
| 992 | ); |
| 993 | |
| 994 | return Effect.gen(function* () { |
| 995 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 996 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 997 | yield* provider.provision({ |
| 998 | ...key, |
| 999 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1000 | }); |
| 1001 | expect(yield* provider.release({ ...key, markReleased: true })).toBe(true); |
| 1002 | |
| 1003 | // The first claim only reserves the release; the second, taken with the |
| 1004 | // row locked right before the delete, is the one that records it. |
| 1005 | const claims = allocationCalls.filter((call) => call.operation === "claimRelease"); |
| 1006 | expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual( |
| 1007 | [undefined, true], |
| 1008 | ); |
| 1009 | }).pipe(Effect.provide(layer)); |
| 1010 | }); |
| 1011 | |
| 1012 | it.effect("treats an environment without a recorded tunnel as already released", () => { |
| 1013 | const tunnelCalls: TunnelCall[] = []; |
| 1014 | const dnsCalls: DnsCall[] = []; |
| 1015 | const layer = layerProvider( |
| 1016 | makePersistentTunnelClient(tunnelCalls), |
| 1017 | makeDnsClient(dnsCalls), |
| 1018 | makeAllocations(), |
| 1019 | ); |
| 1020 | |
| 1021 | return Effect.gen(function* () { |
| 1022 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1023 | const released = yield* provider.release({ userId: "user_ABC", environmentId: "env_ABC" }); |
| 1024 | |
| 1025 | expect(released).toBe(true); |
| 1026 | expect(tunnelCalls).toEqual([]); |
| 1027 | expect(dnsCalls).toEqual([]); |
| 1028 | }).pipe(Effect.provide(layer)); |
| 1029 | }); |
| 1030 | |
| 1031 | it.effect("keeps the tunnel alive when a concurrent provision outdates the release claim", () => { |
| 1032 | const tunnelCalls: TunnelCall[] = []; |
| 1033 | const allocations = makeAllocations(); |
| 1034 | // Simulates a provision racing the release: the allocation generation no |
| 1035 | // longer matches what the release loaded, so the claim fails. |
| 1036 | const outdated = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1037 | ...allocations, |
| 1038 | claimRelease: () => Effect.succeed(null), |
| 1039 | }); |
| 1040 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), outdated); |
| 1041 | |
| 1042 | return Effect.gen(function* () { |
| 1043 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1044 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1045 | yield* provider.provision({ |
| 1046 | ...key, |
| 1047 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1048 | }); |
| 1049 | const released = yield* provider.release(key); |
| 1050 | |
| 1051 | // false tells the caller its connector token is still live, so it must |
| 1052 | // keep its runtime config. |
| 1053 | expect(released).toBe(false); |
| 1054 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ |
| 1055 | "list", |
| 1056 | "create", |
| 1057 | "putConfiguration", |
| 1058 | "getToken", |
| 1059 | ]); |
| 1060 | }).pipe(Effect.provide(layer)); |
| 1061 | }); |
| 1062 | |
| 1063 | it.effect("does not release a tunnel when the requested tunnel id is outdated", () => { |
| 1064 | const tunnelCalls: TunnelCall[] = []; |
| 1065 | const layer = layerProvider( |
| 1066 | makePersistentTunnelClient(tunnelCalls), |
| 1067 | makeDnsClient(), |
| 1068 | makeAllocations(), |
| 1069 | ); |
| 1070 | |
| 1071 | return Effect.gen(function* () { |
| 1072 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1073 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1074 | yield* provider.provision({ |
| 1075 | ...key, |
| 1076 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1077 | }); |
| 1078 | |
| 1079 | expect(yield* provider.release({ ...key, expectedTunnelId: "old-tunnel-id" })).toBe(false); |
| 1080 | expect(tunnelCalls.map((call) => call.operation)).not.toContain("delete"); |
| 1081 | }).pipe(Effect.provide(layer)); |
| 1082 | }); |
| 1083 | |
| 1084 | it.effect("keeps a tunnel when a provision replaces an ordinary release generation", () => { |
| 1085 | const tunnelCalls: TunnelCall[] = []; |
| 1086 | const allocations = makeAllocations(); |
| 1087 | let replaceAfterClaim = false; |
| 1088 | const replaced = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1089 | ...allocations, |
| 1090 | claimRelease: (input) => |
| 1091 | allocations.claimRelease(input).pipe( |
| 1092 | Effect.tap((claimedGeneration) => { |
| 1093 | if (claimedGeneration === null || replaceAfterClaim) return Effect.void; |
| 1094 | replaceAfterClaim = true; |
| 1095 | return allocations |
| 1096 | .recordTunnel({ |
| 1097 | userId: input.userId, |
| 1098 | environmentId: input.environmentId, |
| 1099 | tunnelId: "replacement-tunnel", |
| 1100 | generation: claimedGeneration, |
| 1101 | }) |
| 1102 | .pipe(Effect.asVoid); |
| 1103 | }), |
| 1104 | ), |
| 1105 | }); |
| 1106 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), replaced); |
| 1107 | |
| 1108 | return Effect.gen(function* () { |
| 1109 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1110 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1111 | yield* provider.provision({ |
| 1112 | ...key, |
| 1113 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1114 | }); |
| 1115 | |
| 1116 | expect(yield* provider.release(key)).toBe(false); |
| 1117 | expect(tunnelCalls.map((call) => call.operation)).not.toContain("delete"); |
| 1118 | }).pipe(Effect.provide(layer)); |
| 1119 | }); |
| 1120 | |
| 1121 | it.effect("only confirms the tunnel exists when the registered origin is unchanged", () => { |
| 1122 | const tunnelCalls: TunnelCall[] = []; |
| 1123 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); |
| 1124 | |
| 1125 | return Effect.gen(function* () { |
| 1126 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1127 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1128 | const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; |
| 1129 | const provisioned = yield* provider.provision({ ...key, origin }); |
| 1130 | tunnelCalls.length = 0; |
| 1131 | |
| 1132 | expect( |
| 1133 | yield* provider.reconcileOrigin({ |
| 1134 | ...key, |
| 1135 | tunnelId: provisioned.runtime.tunnelId!, |
| 1136 | origin, |
| 1137 | endpoint: provisioned.endpoint, |
| 1138 | }), |
| 1139 | ).toBe("ready"); |
| 1140 | expect(tunnelCalls).toEqual([{ operation: "get", input: "tunnel-id" }]); |
| 1141 | }).pipe(Effect.provide(layer)); |
| 1142 | }); |
| 1143 | |
| 1144 | it.effect("asks for recovery when the recorded tunnel was deleted", () => { |
| 1145 | const tunnelCalls: TunnelCall[] = []; |
| 1146 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); |
| 1147 | |
| 1148 | return Effect.gen(function* () { |
| 1149 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1150 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1151 | const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; |
| 1152 | const provisioned = yield* provider.provision({ ...key, origin }); |
| 1153 | // A shutdown release deletes the tunnel but keeps the recorded id; the |
| 1154 | // host was killed before it dropped its stored config. |
| 1155 | expect(yield* provider.release(key)).toBe(true); |
| 1156 | |
| 1157 | expect( |
| 1158 | yield* provider.reconcileOrigin({ |
| 1159 | ...key, |
| 1160 | tunnelId: provisioned.runtime.tunnelId!, |
| 1161 | origin, |
| 1162 | endpoint: provisioned.endpoint, |
| 1163 | }), |
| 1164 | ).toBe("recovery_required"); |
| 1165 | }).pipe(Effect.provide(layer)); |
| 1166 | }); |
| 1167 | |
| 1168 | it.effect("updates Cloudflare ingress once when the registered port changes", () => { |
| 1169 | const tunnelCalls: TunnelCall[] = []; |
| 1170 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); |
| 1171 | |
| 1172 | return Effect.gen(function* () { |
| 1173 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1174 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1175 | const provisioned = yield* provider.provision({ |
| 1176 | ...key, |
| 1177 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1178 | }); |
| 1179 | tunnelCalls.length = 0; |
| 1180 | |
| 1181 | expect( |
| 1182 | yield* provider.reconcileOrigin({ |
| 1183 | ...key, |
| 1184 | tunnelId: provisioned.runtime.tunnelId!, |
| 1185 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, |
| 1186 | endpoint: provisioned.endpoint, |
| 1187 | }), |
| 1188 | ).toBe("ready"); |
| 1189 | expect(tunnelCalls).toEqual([ |
| 1190 | { operation: "get", input: "tunnel-id" }, |
| 1191 | { |
| 1192 | operation: "putConfiguration", |
| 1193 | input: { |
| 1194 | tunnelId: "tunnel-id", |
| 1195 | tunnelConfig: { |
| 1196 | ingress: [ |
| 1197 | { |
| 1198 | hostname: expectedManagedHostname("env_ABC"), |
| 1199 | service: "http://127.0.0.1:4884", |
| 1200 | }, |
| 1201 | { service: "http_status:404" }, |
| 1202 | ], |
| 1203 | }, |
| 1204 | }, |
| 1205 | }, |
| 1206 | ]); |
| 1207 | }).pipe(Effect.provide(layer)); |
| 1208 | }); |
| 1209 | |
| 1210 | it.effect("requires recovery when Cloudflare reports the registered tunnel is missing", () => { |
| 1211 | const tunnelCalls: TunnelCall[] = []; |
| 1212 | const baseTunnelClient = makePersistentTunnelClient(tunnelCalls); |
| 1213 | let tunnelMissing = false; |
| 1214 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1215 | ...baseTunnelClient, |
| 1216 | putConfiguration: (tunnelId, tunnelConfig) => |
| 1217 | tunnelMissing |
| 1218 | ? Effect.fail( |
| 1219 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1220 | operation: "put-configuration", |
| 1221 | tunnelId, |
| 1222 | cause: { _tag: "TunnelNotFound" }, |
| 1223 | }), |
| 1224 | ) |
| 1225 | : baseTunnelClient.putConfiguration(tunnelId, tunnelConfig), |
| 1226 | }); |
| 1227 | const layer = layerProvider(tunnelClient); |
| 1228 | |
| 1229 | return Effect.gen(function* () { |
| 1230 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1231 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1232 | const provisioned = yield* provider.provision({ |
| 1233 | ...key, |
| 1234 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1235 | }); |
| 1236 | tunnelMissing = true; |
| 1237 | |
| 1238 | expect( |
| 1239 | yield* provider.reconcileOrigin({ |
| 1240 | ...key, |
| 1241 | tunnelId: provisioned.runtime.tunnelId!, |
| 1242 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, |
| 1243 | endpoint: provisioned.endpoint, |
| 1244 | }), |
| 1245 | ).toBe("recovery_required"); |
| 1246 | }).pipe(Effect.provide(layer)); |
| 1247 | }); |
| 1248 | |
| 1249 | it.effect("fails origin sync when the allocation generation changes", () => { |
| 1250 | const allocations = makeAllocations(); |
| 1251 | let loseClaim = false; |
| 1252 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1253 | ...allocations, |
| 1254 | withClaimedTunnel: (input, effect) => |
| 1255 | loseClaim ? Effect.succeedNone : allocations.withClaimedTunnel(input, effect), |
| 1256 | }); |
| 1257 | const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(), changed); |
| 1258 | |
| 1259 | return Effect.gen(function* () { |
| 1260 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1261 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1262 | const provisioned = yield* provider.provision({ |
| 1263 | ...key, |
| 1264 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1265 | }); |
| 1266 | loseClaim = true; |
| 1267 | |
| 1268 | const error = yield* Effect.flip( |
| 1269 | provider.reconcileOrigin({ |
| 1270 | ...key, |
| 1271 | tunnelId: provisioned.runtime.tunnelId!, |
| 1272 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, |
| 1273 | endpoint: provisioned.endpoint, |
| 1274 | }), |
| 1275 | ); |
| 1276 | expect(error).toMatchObject({ |
| 1277 | _tag: "ManagedEndpointProvisioningFailed", |
| 1278 | stage: "sync-origin", |
| 1279 | }); |
| 1280 | }).pipe(Effect.provide(layer)); |
| 1281 | }); |
| 1282 | |
| 1283 | it.effect("rejects an active endpoint that does not match the allocation hostname", () => { |
| 1284 | const layer = layerProvider(makePersistentTunnelClient()); |
| 1285 | |
| 1286 | return Effect.gen(function* () { |
| 1287 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1288 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1289 | const provisioned = yield* provider.provision({ |
| 1290 | ...key, |
| 1291 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1292 | }); |
| 1293 | |
| 1294 | const error = yield* Effect.flip( |
| 1295 | provider.reconcileOrigin({ |
| 1296 | ...key, |
| 1297 | tunnelId: provisioned.runtime.tunnelId!, |
| 1298 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1299 | endpoint: { |
| 1300 | ...provisioned.endpoint, |
| 1301 | httpBaseUrl: "https://different-host.t3code.test/", |
| 1302 | }, |
| 1303 | }), |
| 1304 | ); |
| 1305 | expect(error).toMatchObject({ |
| 1306 | _tag: "ManagedEndpointProvisioningFailed", |
| 1307 | stage: "verify-endpoint", |
| 1308 | }); |
| 1309 | }).pipe(Effect.provide(layer)); |
| 1310 | }); |
| 1311 | |
| 1312 | it.effect( |
| 1313 | "keeps a newly created tunnel available for retry after losing its allocation claim", |
| 1314 | () => { |
| 1315 | const tunnelCalls: TunnelCall[] = []; |
| 1316 | const allocations = makeAllocations(); |
| 1317 | let changeGeneration = true; |
| 1318 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1319 | ...allocations, |
| 1320 | recordTunnel: (input) => |
| 1321 | Effect.gen(function* () { |
| 1322 | if (changeGeneration) { |
| 1323 | changeGeneration = false; |
| 1324 | yield* allocations.claimDeprovision(input); |
| 1325 | } |
| 1326 | return yield* allocations.recordTunnel(input); |
| 1327 | }), |
| 1328 | }); |
| 1329 | const layer = layerProvider( |
| 1330 | makePersistentTunnelClient(tunnelCalls), |
| 1331 | makeDnsClient(), |
| 1332 | changed, |
| 1333 | ); |
| 1334 | |
| 1335 | return Effect.gen(function* () { |
| 1336 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1337 | const input = { |
| 1338 | userId: "user_ABC", |
| 1339 | environmentId: "env_ABC", |
| 1340 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1341 | }; |
| 1342 | const error = yield* Effect.flip(provider.provision(input)); |
| 1343 | |
| 1344 | expect(error).toMatchObject({ |
| 1345 | _tag: "ManagedEndpointProvisioningFailed", |
| 1346 | stage: "record-tunnel", |
| 1347 | reason: "claim-lost", |
| 1348 | }); |
| 1349 | expect(tunnelCalls.map((call) => call.operation)).toEqual(["list", "create"]); |
| 1350 | expect((yield* provider.provision(input)).runtime.tunnelId).toBe("tunnel-id"); |
| 1351 | expect(tunnelCalls.filter((call) => call.operation === "create")).toHaveLength(1); |
| 1352 | expect(tunnelCalls.filter((call) => call.operation === "delete")).toEqual([]); |
| 1353 | }).pipe(Effect.provide(layer)); |
| 1354 | }, |
| 1355 | ); |
| 1356 | |
| 1357 | it.effect("does not overwrite DNS when tunnel ownership changes during provisioning", () => { |
| 1358 | const allocations = makeAllocations(); |
| 1359 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1360 | ...allocations, |
| 1361 | recordDns: () => Effect.succeed(null), |
| 1362 | }); |
| 1363 | const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(), changed); |
| 1364 | |
| 1365 | return Effect.gen(function* () { |
| 1366 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1367 | const error = yield* Effect.flip( |
| 1368 | provider.provision({ |
| 1369 | userId: "user_ABC", |
| 1370 | environmentId: "env_ABC", |
| 1371 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1372 | }), |
| 1373 | ); |
| 1374 | |
| 1375 | expect(error).toMatchObject({ |
| 1376 | _tag: "ManagedEndpointProvisioningFailed", |
| 1377 | stage: "record-dns", |
| 1378 | }); |
| 1379 | }).pipe(Effect.provide(layer)); |
| 1380 | }); |
| 1381 | |
| 1382 | it.effect("does not change tunnel ingress after another provision takes ownership", () => { |
| 1383 | const tunnelCalls: TunnelCall[] = []; |
| 1384 | const allocations = makeAllocations(); |
| 1385 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1386 | ...allocations, |
| 1387 | withClaimedTunnel: () => Effect.succeedNone, |
| 1388 | }); |
| 1389 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), changed); |
| 1390 | |
| 1391 | return Effect.gen(function* () { |
| 1392 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1393 | const error = yield* Effect.flip( |
| 1394 | provider.provision({ |
| 1395 | userId: "user_ABC", |
| 1396 | environmentId: "env_ABC", |
| 1397 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1398 | }), |
| 1399 | ); |
| 1400 | |
| 1401 | expect(error).toMatchObject({ |
| 1402 | _tag: "ManagedEndpointProvisioningFailed", |
| 1403 | stage: "configure-tunnel", |
| 1404 | reason: "claim-lost", |
| 1405 | }); |
| 1406 | expect(tunnelCalls.map((call) => call.operation)).not.toContain("putConfiguration"); |
| 1407 | }).pipe(Effect.provide(layer)); |
| 1408 | }); |
| 1409 | |
| 1410 | it.effect("does not change DNS after another provision takes ownership", () => { |
| 1411 | const dnsCalls: DnsCall[] = []; |
| 1412 | const allocations = makeAllocations(); |
| 1413 | let lockCount = 0; |
| 1414 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1415 | ...allocations, |
| 1416 | withClaimedTunnel: (input, effect) => |
| 1417 | ++lockCount === 1 ? allocations.withClaimedTunnel(input, effect) : Effect.succeedNone, |
| 1418 | }); |
| 1419 | const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(dnsCalls), changed); |
| 1420 | |
| 1421 | return Effect.gen(function* () { |
| 1422 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1423 | const error = yield* Effect.flip( |
| 1424 | provider.provision({ |
| 1425 | userId: "user_ABC", |
| 1426 | environmentId: "env_ABC", |
| 1427 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1428 | }), |
| 1429 | ); |
| 1430 | |
| 1431 | expect(error).toMatchObject({ |
| 1432 | _tag: "ManagedEndpointProvisioningFailed", |
| 1433 | stage: "record-dns", |
| 1434 | }); |
| 1435 | expect(dnsCalls).toEqual([]); |
| 1436 | }).pipe(Effect.provide(layer)); |
| 1437 | }); |
| 1438 | |
| 1439 | it.effect("does not mark a superseded tunnel allocation as ready", () => { |
| 1440 | const allocations = makeAllocations(); |
| 1441 | const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1442 | ...allocations, |
| 1443 | markReady: () => Effect.succeed(false), |
| 1444 | }); |
| 1445 | const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(), changed); |
| 1446 | |
| 1447 | return Effect.gen(function* () { |
| 1448 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1449 | const error = yield* Effect.flip( |
| 1450 | provider.provision({ |
| 1451 | userId: "user_ABC", |
| 1452 | environmentId: "env_ABC", |
| 1453 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1454 | }), |
| 1455 | ); |
| 1456 | |
| 1457 | expect(error).toMatchObject({ |
| 1458 | _tag: "ManagedEndpointProvisioningFailed", |
| 1459 | stage: "mark-allocation-ready", |
| 1460 | reason: "claim-lost", |
| 1461 | }); |
| 1462 | }).pipe(Effect.provide(layer)); |
| 1463 | }); |
| 1464 | |
| 1465 | it.effect("keeps a tunnel that reconnects before scheduled deletion", () => { |
| 1466 | const tunnelCalls: TunnelCall[] = []; |
| 1467 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1468 | ...makePersistentTunnelClient(tunnelCalls), |
| 1469 | get: (tunnelId) => |
| 1470 | Effect.succeed({ |
| 1471 | id: tunnelId, |
| 1472 | name: expectedManagedTunnelName("env_ABC"), |
| 1473 | status: "healthy", |
| 1474 | connsInactiveAt: null, |
| 1475 | }), |
| 1476 | }); |
| 1477 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations()); |
| 1478 | |
| 1479 | return Effect.gen(function* () { |
| 1480 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1481 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1482 | yield* provider.provision({ |
| 1483 | ...key, |
| 1484 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1485 | }); |
| 1486 | |
| 1487 | expect( |
| 1488 | yield* provider.release({ |
| 1489 | ...key, |
| 1490 | expectedTunnelId: "tunnel-id", |
| 1491 | expectedStatus: "down", |
| 1492 | expectedInactiveBefore: "2026-06-01T00:05:00.000Z", |
| 1493 | }), |
| 1494 | ).toBe(false); |
| 1495 | expect(tunnelCalls.map((call) => call.operation)).not.toContain("delete"); |
| 1496 | }).pipe(Effect.provide(layer)); |
| 1497 | }); |
| 1498 | |
| 1499 | it.effect("keeps a tunnel when a new provision replaces the release generation", () => { |
| 1500 | const tunnelCalls: TunnelCall[] = []; |
| 1501 | const allocations = makeAllocations(); |
| 1502 | const replaced = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 1503 | ...allocations, |
| 1504 | claimRelease: (input) => |
| 1505 | allocations.claimRelease(input).pipe( |
| 1506 | Effect.tap((claimedGeneration) => |
| 1507 | claimedGeneration === null |
| 1508 | ? Effect.void |
| 1509 | : allocations |
| 1510 | .recordTunnel({ |
| 1511 | userId: input.userId, |
| 1512 | environmentId: input.environmentId, |
| 1513 | tunnelId: "replacement-tunnel", |
| 1514 | generation: claimedGeneration, |
| 1515 | }) |
| 1516 | .pipe(Effect.asVoid), |
| 1517 | ), |
| 1518 | ), |
| 1519 | }); |
| 1520 | const layer = layerProvider(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), replaced); |
| 1521 | |
| 1522 | return Effect.gen(function* () { |
| 1523 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1524 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1525 | yield* provider.provision({ |
| 1526 | ...key, |
| 1527 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1528 | }); |
| 1529 | |
| 1530 | expect( |
| 1531 | yield* provider.release({ |
| 1532 | ...key, |
| 1533 | expectedTunnelId: "tunnel-id", |
| 1534 | expectedStatus: "down", |
| 1535 | expectedInactiveBefore: "2026-06-01T00:05:00.000Z", |
| 1536 | }), |
| 1537 | ).toBe(false); |
| 1538 | expect(tunnelCalls.map((call) => call.operation)).not.toContain("delete"); |
| 1539 | }).pipe(Effect.provide(layer)); |
| 1540 | }); |
| 1541 | |
| 1542 | it.effect("treats an already deleted tunnel as successfully released", () => { |
| 1543 | const notFound = { _tag: "NotFound" } as const; |
| 1544 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1545 | ...makeTunnelClient(), |
| 1546 | delete: (tunnelId) => |
| 1547 | Effect.fail( |
| 1548 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1549 | operation: "delete", |
| 1550 | tunnelId, |
| 1551 | cause: notFound, |
| 1552 | }), |
| 1553 | ), |
| 1554 | }); |
| 1555 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations()); |
| 1556 | |
| 1557 | return Effect.gen(function* () { |
| 1558 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1559 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1560 | yield* provider.provision({ |
| 1561 | ...key, |
| 1562 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1563 | }); |
| 1564 | yield* provider.release(key); |
| 1565 | }).pipe(Effect.provide(layer)); |
| 1566 | }); |
| 1567 | |
| 1568 | it.effect.each([ |
| 1569 | { name: "the tunnel is gone", tunnelRemoved: true, released: true }, |
| 1570 | { name: "the tunnel still exists", tunnelRemoved: false, released: false }, |
| 1571 | ] as const)( |
| 1572 | "resolves a delete whose response was lost by checking whether $name", |
| 1573 | ({ tunnelRemoved, released }) => { |
| 1574 | const persistent = makePersistentTunnelClient(); |
| 1575 | const lostResponse = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1576 | operation: "delete", |
| 1577 | tunnelId: "tunnel-id", |
| 1578 | cause: { _tag: "TimeoutError" }, |
| 1579 | }); |
| 1580 | // Cloudflare may or may not have applied the delete before the |
| 1581 | // response was lost; the client only sees the timeout. |
| 1582 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1583 | ...persistent, |
| 1584 | delete: (tunnelId) => |
| 1585 | (tunnelRemoved ? persistent.delete(tunnelId) : Effect.void).pipe( |
| 1586 | Effect.andThen(Effect.fail(lostResponse)), |
| 1587 | ), |
| 1588 | }); |
| 1589 | const allocationCalls: AllocationCall[] = []; |
| 1590 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls)); |
| 1591 | |
| 1592 | return Effect.gen(function* () { |
| 1593 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1594 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1595 | yield* provider.provision({ |
| 1596 | ...key, |
| 1597 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1598 | }); |
| 1599 | const result = yield* Effect.result(provider.release({ ...key, markReleased: true })); |
| 1600 | |
| 1601 | if (released) { |
| 1602 | // Succeeding commits the marked claim, so status can explain it. |
| 1603 | expect(result).toMatchObject({ _tag: "Success", success: true }); |
| 1604 | } else { |
| 1605 | // Failing rolls the claim back; the tunnel is still there to retry. |
| 1606 | expect(result).toMatchObject({ |
| 1607 | _tag: "Failure", |
| 1608 | failure: { stage: "delete-tunnel", cause: lostResponse }, |
| 1609 | }); |
| 1610 | } |
| 1611 | }).pipe(Effect.provide(layer)); |
| 1612 | }, |
| 1613 | ); |
| 1614 | |
| 1615 | it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => { |
| 1616 | const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1617 | operation: "delete", |
| 1618 | tunnelId: "tunnel-id", |
| 1619 | cause: "Cloudflare tunnel deletion failed", |
| 1620 | }); |
| 1621 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1622 | ...makeTunnelClient(), |
| 1623 | delete: () => Effect.fail(failure), |
| 1624 | }); |
| 1625 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations()); |
| 1626 | |
| 1627 | return Effect.gen(function* () { |
| 1628 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1629 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1630 | yield* provider.provision({ |
| 1631 | ...key, |
| 1632 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1633 | }); |
| 1634 | const error = yield* Effect.flip(provider.release(key)); |
| 1635 | |
| 1636 | expect(error).toMatchObject({ |
| 1637 | _tag: "ManagedEndpointDeprovisioningFailed", |
| 1638 | stage: "delete-tunnel", |
| 1639 | userId: key.userId, |
| 1640 | environmentId: key.environmentId, |
| 1641 | tunnelId: "tunnel-id", |
| 1642 | }); |
| 1643 | expect(error.cause).toBe(failure); |
| 1644 | }).pipe(Effect.provide(layer)); |
| 1645 | }); |
| 1646 | |
| 1647 | it.effect("reports a tunnel with an attached connector as not released", () => { |
| 1648 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1649 | ...makeTunnelClient(), |
| 1650 | delete: (tunnelId) => |
| 1651 | Effect.fail( |
| 1652 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1653 | operation: "delete", |
| 1654 | tunnelId, |
| 1655 | cause: { |
| 1656 | _tag: "BadRequest", |
| 1657 | code: 1022, |
| 1658 | message: |
| 1659 | "This tunnel has active connections. Please stop all cloudflared replicas, or wait a few minutes for connections to close, then try again.", |
| 1660 | }, |
| 1661 | }), |
| 1662 | ), |
| 1663 | }); |
| 1664 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations()); |
| 1665 | |
| 1666 | return Effect.gen(function* () { |
| 1667 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1668 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1669 | yield* provider.provision({ |
| 1670 | ...key, |
| 1671 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1672 | }); |
| 1673 | expect(yield* provider.release(key)).toBe(false); |
| 1674 | }).pipe(Effect.provide(layer)); |
| 1675 | }); |
| 1676 | |
| 1677 | it.effect("treats an absent allocation as already deprovisioned", () => { |
| 1678 | const tunnelCalls: TunnelCall[] = []; |
| 1679 | const dnsCalls: DnsCall[] = []; |
| 1680 | const allocationCalls: AllocationCall[] = []; |
| 1681 | const layer = layerProvider( |
| 1682 | makePersistentTunnelClient(tunnelCalls), |
| 1683 | makeDnsClient(dnsCalls), |
| 1684 | makeAllocations(allocationCalls), |
| 1685 | ); |
| 1686 | |
| 1687 | return Effect.gen(function* () { |
| 1688 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1689 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1690 | yield* provider.deprovision(key); |
| 1691 | |
| 1692 | expect(tunnelCalls).toEqual([]); |
| 1693 | expect(dnsCalls).toEqual([]); |
| 1694 | expect(allocationCalls).toEqual([{ operation: "get", input: key }]); |
| 1695 | }).pipe(Effect.provide(layer)); |
| 1696 | }); |
| 1697 | |
| 1698 | it.effect("keeps the allocation when tunnel cleanup fails so unlink can retry", () => { |
| 1699 | const allocationCalls: AllocationCall[] = []; |
| 1700 | const tunnelCalls: TunnelCall[] = []; |
| 1701 | let deleteAttempts = 0; |
| 1702 | const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1703 | operation: "delete", |
| 1704 | tunnelId: "tunnel-id", |
| 1705 | cause: "Cloudflare tunnel deletion failed", |
| 1706 | }); |
| 1707 | const tunnels = makePersistentTunnelClient(tunnelCalls); |
| 1708 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1709 | ...tunnels, |
| 1710 | delete: (tunnelId) => |
| 1711 | Effect.gen(function* () { |
| 1712 | tunnelCalls.push({ operation: "delete", input: tunnelId }); |
| 1713 | deleteAttempts++; |
| 1714 | if (deleteAttempts === 1) { |
| 1715 | return yield* failure; |
| 1716 | } |
| 1717 | }), |
| 1718 | }); |
| 1719 | const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls)); |
| 1720 | |
| 1721 | return Effect.gen(function* () { |
| 1722 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1723 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1724 | yield* provider.provision({ |
| 1725 | ...key, |
| 1726 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1727 | }); |
| 1728 | const first = yield* Effect.result(provider.deprovision(key)); |
| 1729 | expect(first._tag).toBe("Failure"); |
| 1730 | if (first._tag === "Failure") { |
| 1731 | expect(first.failure).toMatchObject({ |
| 1732 | _tag: "ManagedEndpointDeprovisioningFailed", |
| 1733 | stage: "delete-tunnel", |
| 1734 | userId: key.userId, |
| 1735 | environmentId: key.environmentId, |
| 1736 | tunnelId: "tunnel-id", |
| 1737 | }); |
| 1738 | expect(first.failure.cause).toBe(failure); |
| 1739 | } |
| 1740 | yield* provider.deprovision(key); |
| 1741 | |
| 1742 | expect(allocationCalls.map((call) => call.operation)).toEqual([ |
| 1743 | "reserve", |
| 1744 | "recordTunnel", |
| 1745 | "recordDns", |
| 1746 | "markReady", |
| 1747 | "get", |
| 1748 | "claimDeprovision", |
| 1749 | "get", |
| 1750 | "claimDeprovision", |
| 1751 | "removeClaimed", |
| 1752 | ]); |
| 1753 | }).pipe(Effect.provide(layer)); |
| 1754 | }); |
| 1755 | |
| 1756 | it.effect("treats already deleted remote resources as successfully deprovisioned", () => { |
| 1757 | const allocationCalls: AllocationCall[] = []; |
| 1758 | const notFound = { _tag: "NotFound" } as const; |
| 1759 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1760 | ...makeTunnelClient(), |
| 1761 | delete: () => |
| 1762 | Effect.fail( |
| 1763 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ |
| 1764 | operation: "delete", |
| 1765 | tunnelId: "tunnel-id", |
| 1766 | cause: notFound, |
| 1767 | }), |
| 1768 | ), |
| 1769 | }); |
| 1770 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ |
| 1771 | ...makeDnsClient(), |
| 1772 | deleteRecord: () => |
| 1773 | Effect.fail( |
| 1774 | new ManagedEndpointProvider.ManagedEndpointDnsClientError({ |
| 1775 | operation: "delete-record", |
| 1776 | dnsRecordId: "created-record-id", |
| 1777 | cause: notFound, |
| 1778 | }), |
| 1779 | ), |
| 1780 | }); |
| 1781 | const layer = layerProvider(tunnelClient, dnsClient, makeAllocations(allocationCalls)); |
| 1782 | |
| 1783 | return Effect.gen(function* () { |
| 1784 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1785 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; |
| 1786 | yield* provider.provision({ |
| 1787 | ...key, |
| 1788 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1789 | }); |
| 1790 | yield* provider.deprovision(key); |
| 1791 | |
| 1792 | expect(allocationCalls.map((call) => call.operation)).toContain("removeClaimed"); |
| 1793 | }).pipe(Effect.provide(layer)); |
| 1794 | }); |
| 1795 | |
| 1796 | it.effect("scopes managed endpoint resources by user", () => { |
| 1797 | const tunnelCalls: TunnelCall[] = []; |
| 1798 | |
| 1799 | return Effect.gen(function* () { |
| 1800 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1801 | yield* provider.provision({ |
| 1802 | userId: "user_ABC", |
| 1803 | environmentId: "env_shared", |
| 1804 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1805 | }); |
| 1806 | yield* provider.provision({ |
| 1807 | userId: "user_DEF", |
| 1808 | environmentId: "env_shared", |
| 1809 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1810 | }); |
| 1811 | |
| 1812 | expect( |
| 1813 | tunnelCalls.filter((call) => call.operation === "list").map((call) => call.input), |
| 1814 | ).toEqual([ |
| 1815 | { name: expectedManagedTunnelName("env_shared", "user_ABC"), isDeleted: false }, |
| 1816 | { name: expectedManagedTunnelName("env_shared", "user_DEF"), isDeleted: false }, |
| 1817 | ]); |
| 1818 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(tunnelCalls)))); |
| 1819 | }); |
| 1820 | |
| 1821 | it.effect("recovers when DNS creation reports failure after the record became visible", () => { |
| 1822 | const dnsCalls: DnsCall[] = []; |
| 1823 | const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ |
| 1824 | operation: "create-record", |
| 1825 | hostname: expectedManagedHostname("env_ABC"), |
| 1826 | cause: "ambiguous Cloudflare DNS response", |
| 1827 | }); |
| 1828 | let records: ReadonlyArray<{ readonly id: string }> = []; |
| 1829 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ |
| 1830 | listRecords: (hostname) => |
| 1831 | Effect.sync(() => { |
| 1832 | dnsCalls.push({ operation: "listRecords", input: hostname }); |
| 1833 | return records; |
| 1834 | }), |
| 1835 | createRecord: (request) => |
| 1836 | Effect.gen(function* () { |
| 1837 | dnsCalls.push({ operation: "createRecord", input: request }); |
| 1838 | records = [{ id: "created-record-id" }]; |
| 1839 | return yield* failure; |
| 1840 | }), |
| 1841 | updateRecord: (dnsRecordId, request) => |
| 1842 | Effect.sync(() => { |
| 1843 | dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); |
| 1844 | }), |
| 1845 | deleteRecord: (dnsRecordId) => |
| 1846 | Effect.sync(() => { |
| 1847 | dnsCalls.push({ operation: "deleteRecord", input: dnsRecordId }); |
| 1848 | }), |
| 1849 | }); |
| 1850 | |
| 1851 | return Effect.gen(function* () { |
| 1852 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1853 | yield* provider.provision({ |
| 1854 | userId: "user_ABC", |
| 1855 | environmentId: "env_ABC", |
| 1856 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1857 | }); |
| 1858 | |
| 1859 | expect(dnsCalls.map((call) => call.operation)).toEqual([ |
| 1860 | "listRecords", |
| 1861 | "createRecord", |
| 1862 | "listRecords", |
| 1863 | "updateRecord", |
| 1864 | ]); |
| 1865 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(), dnsClient))); |
| 1866 | }); |
| 1867 | |
| 1868 | it.effect("reports mismatched tunnel responses without manufacturing a cause", () => { |
| 1869 | const dnsCalls: DnsCall[] = []; |
| 1870 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ |
| 1871 | ...makeTunnelClient(), |
| 1872 | create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }), |
| 1873 | }); |
| 1874 | |
| 1875 | return Effect.gen(function* () { |
| 1876 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1877 | const error = yield* Effect.flip( |
| 1878 | provider.provision({ |
| 1879 | userId: "user_ABC", |
| 1880 | environmentId: "env_ABC", |
| 1881 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1882 | }), |
| 1883 | ); |
| 1884 | |
| 1885 | expect(error).toMatchObject({ |
| 1886 | _tag: "ManagedEndpointProvisioningFailed", |
| 1887 | stage: "validate-tunnel-response", |
| 1888 | userId: "user_ABC", |
| 1889 | environmentId: "env_ABC", |
| 1890 | hostname: expectedManagedHostname("env_ABC"), |
| 1891 | tunnelName: expectedManagedTunnelName("env_ABC"), |
| 1892 | returnedTunnelId: "returned-tunnel-id", |
| 1893 | returnedTunnelName: "unexpected-tunnel", |
| 1894 | }); |
| 1895 | if (error._tag === "ManagedEndpointProvisioningFailed") { |
| 1896 | expect(error.cause).toBeUndefined(); |
| 1897 | } |
| 1898 | expect(dnsCalls).toHaveLength(0); |
| 1899 | }).pipe(Effect.provide(layerProvider(tunnelClient, makeDnsClient(dnsCalls)))); |
| 1900 | }); |
| 1901 | |
| 1902 | it.effect("fails provisioning when the DNS client fails", () => { |
| 1903 | const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ |
| 1904 | operation: "list-records", |
| 1905 | hostname: expectedManagedHostname("env_ABC"), |
| 1906 | cause: "Cloudflare DNS failure", |
| 1907 | }); |
| 1908 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ |
| 1909 | listRecords: () => Effect.fail(failure), |
| 1910 | createRecord: () => Effect.die("unused"), |
| 1911 | updateRecord: () => Effect.die("unused"), |
| 1912 | deleteRecord: () => Effect.die("unused"), |
| 1913 | }); |
| 1914 | |
| 1915 | return Effect.gen(function* () { |
| 1916 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 1917 | const error = yield* Effect.flip( |
| 1918 | provider.provision({ |
| 1919 | userId: "user_ABC", |
| 1920 | environmentId: "env_ABC", |
| 1921 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, |
| 1922 | }), |
| 1923 | ); |
| 1924 | |
| 1925 | expect(error).toMatchObject({ |
| 1926 | _tag: "ManagedEndpointProvisioningFailed", |
| 1927 | stage: "ensure-dns-record", |
| 1928 | userId: "user_ABC", |
| 1929 | environmentId: "env_ABC", |
| 1930 | hostname: expectedManagedHostname("env_ABC"), |
| 1931 | tunnelName: expectedManagedTunnelName("env_ABC"), |
| 1932 | tunnelId: "tunnel-id", |
| 1933 | }); |
| 1934 | if (error._tag === "ManagedEndpointProvisioningFailed") { |
| 1935 | expect(error.cause).toBe(failure); |
| 1936 | } |
| 1937 | }).pipe(Effect.provide(layerProvider(makeTunnelClient(), dnsClient))); |
| 1938 | }); |
| 1939 | }); |