| author | |
| committer | |
| log | 96398e3775fc6c74525e4a211e75e580c6cf4225 |
| tree | 30c82c624ae9daa8b2edb5ebfcaf617ac64cc78d |
| parent | d2d40b6c1aa2464b38357d52b7f991cfb422a669 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>10 files changed, 722 insertions(+), 17 deletions(-)
apps/server/src/cloud/http.test.ts+254-1| ... | ... | @@ -4,19 +4,28 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as PlatformError from "effect/PlatformError"; |
| 6 | 6 | import * as Tracer from "effect/Tracer"; |
| 7 | import { HttpClient, HttpServerRequest } from "effect/unstable/http"; | |
| 7 | import { | |
| 8 | HttpClient, | |
| 9 | HttpClientResponse, | |
| 10 | HttpServerRequest, | |
| 11 | type HttpClientRequest, | |
| 12 | } from "effect/unstable/http"; | |
| 8 | 13 | |
| 14 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 9 | 15 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 10 | 16 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; | |
| 13 | 20 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | 21 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; |
| 22 | import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts"; | |
| 15 | 23 | import { |
| 16 | 24 | consumeCloudReplayGuards, |
| 17 | 25 | isSupportedLinkProviderKind, |
| 18 | 26 | linkProofScopes, |
| 19 | 27 | reconcileDesiredCloudLink, |
| 28 | releaseManagedTunnelOnShutdown, | |
| 20 | 29 | } from "./http.ts"; |
| 21 | 30 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 22 | 31 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | ... | @@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => { |
| 213 | 222 | ); |
| 214 | 223 | }); |
| 215 | 224 | |
| 225 | describe("releaseManagedTunnelOnShutdown", () => { | |
| 226 | const cliToken: CliTokenManager.PersistedToken = { | |
| 227 | accessToken: "cli-access-token", | |
| 228 | refreshToken: "cli-refresh-token", | |
| 229 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 230 | }; | |
| 231 | ||
| 232 | function makeMemorySecretStore(initial: Iterable<readonly [string, string]> = []) { | |
| 233 | const values = new Map<string, Uint8Array>( | |
| 234 | Array.from(initial, ([name, value]) => [name, new TextEncoder().encode(value)] as const), | |
| 235 | ); | |
| 236 | const store: ServerSecretStore.ServerSecretStore["Service"] = { | |
| 237 | get: (name) => Effect.sync(() => Option.fromNullishOr(values.get(name))), | |
| 238 | set: (name, value) => | |
| 239 | Effect.sync(() => { | |
| 240 | values.set(name, value); | |
| 241 | }), | |
| 242 | create: unusedSecretStoreOperation, | |
| 243 | getOrCreateRandom: unusedSecretStoreOperation, | |
| 244 | remove: (name) => | |
| 245 | Effect.sync(() => { | |
| 246 | values.delete(name); | |
| 247 | }), | |
| 248 | }; | |
| 249 | return { store, values }; | |
| 250 | } | |
| 251 | ||
| 252 | interface ReleaseHarness { | |
| 253 | readonly store: ServerSecretStore.ServerSecretStore["Service"]; | |
| 254 | readonly applyConfigCalls: Array<unknown>; | |
| 255 | readonly requests: Array<HttpClientRequest.HttpClientRequest>; | |
| 256 | readonly respond?: () => Response; | |
| 257 | } | |
| 258 | ||
| 259 | const provideReleaseHarness = | |
| 260 | (harness: ReleaseHarness) => | |
| 261 | <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 262 | effect.pipe( | |
| 263 | Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), | |
| 264 | Effect.provideService( | |
| 265 | ServerEnvironment.ServerEnvironment, | |
| 266 | ServerEnvironment.ServerEnvironment.of({ | |
| 267 | getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")), | |
| 268 | getDescriptor: Effect.die("unused"), | |
| 269 | }), | |
| 270 | ), | |
| 271 | Effect.provideService( | |
| 272 | ManagedEndpointRuntime.CloudManagedEndpointRuntime, | |
| 273 | ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ | |
| 274 | applyConfig: (config) => | |
| 275 | Effect.sync(() => { | |
| 276 | harness.applyConfigCalls.push(config); | |
| 277 | return { | |
| 278 | status: "disabled", | |
| 279 | } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus; | |
| 280 | }), | |
| 281 | }), | |
| 282 | ), | |
| 283 | Effect.provideService( | |
| 284 | EnvironmentAuth.EnvironmentAuth, | |
| 285 | EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), | |
| 286 | ), | |
| 287 | Effect.provideService( | |
| 288 | CliTokenManager.CloudCliTokenManager, | |
| 289 | CliTokenManager.CloudCliTokenManager.of({ | |
| 290 | get: unusedSecretStoreOperation(), | |
| 291 | getExisting: Effect.succeed(Option.some(cliToken)), | |
| 292 | hasCredential: unusedSecretStoreOperation(), | |
| 293 | store: () => unusedSecretStoreOperation(), | |
| 294 | clear: unusedSecretStoreOperation(), | |
| 295 | }), | |
| 296 | ), | |
| 297 | Effect.provideService( | |
| 298 | HttpClient.HttpClient, | |
| 299 | HttpClient.make((request) => | |
| 300 | Effect.sync(() => { | |
| 301 | harness.requests.push(request); | |
| 302 | return HttpClientResponse.fromWeb( | |
| 303 | request, | |
| 304 | (harness.respond ?? (() => Response.json({ ok: true })))(), | |
| 305 | ); | |
| 306 | }), | |
| 307 | ), | |
| 308 | ), | |
| 309 | ); | |
| 310 | ||
| 311 | // The persisted state of a CLI-managed link whose tunnel is releasable. | |
| 312 | const managedLinkSecrets = [ | |
| 313 | [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], | |
| 314 | [RELAY_URL_SECRET, "https://relay.example.test"], | |
| 315 | [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], | |
| 316 | ] as const; | |
| 317 | ||
| 318 | it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => { | |
| 319 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); | |
| 320 | const applyConfigCalls: Array<unknown> = []; | |
| 321 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 322 | ||
| 323 | return Effect.gen(function* () { | |
| 324 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 325 | ||
| 326 | expect(released).toBe(true); | |
| 327 | expect(applyConfigCalls).toEqual([null]); | |
| 328 | expect(requests).toHaveLength(1); | |
| 329 | const request = requests[0]!; | |
| 330 | expect(request.method).toBe("DELETE"); | |
| 331 | expect(request.url).toBe( | |
| 332 | "https://relay.example.test/v1/client/environment-links/env_123/tunnel", | |
| 333 | ); | |
| 334 | expect(request.headers.authorization).toBe("Bearer cli-access-token"); | |
| 335 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); | |
| 336 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | |
| 337 | }); | |
| 338 | ||
| 339 | it.effect("does nothing for links without a stored managed tunnel runtime config", () => { | |
| 340 | const { store } = makeMemorySecretStore(); | |
| 341 | const applyConfigCalls: Array<unknown> = []; | |
| 342 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 343 | ||
| 344 | return Effect.gen(function* () { | |
| 345 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 346 | ||
| 347 | expect(released).toBe(false); | |
| 348 | expect(applyConfigCalls).toEqual([]); | |
| 349 | expect(requests).toEqual([]); | |
| 350 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | |
| 351 | }); | |
| 352 | ||
| 353 | it.effect("leaves the tunnel of a web/mobile-installed link untouched", () => { | |
| 354 | // A managed runtime config without a CLI-desired link: the environment was | |
| 355 | // linked by a web/mobile client, and nothing re-provisions the tunnel on | |
| 356 | // the next boot, so shutdown must not release it. | |
| 357 | const { store, values } = makeMemorySecretStore([ | |
| 358 | [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], | |
| 359 | [RELAY_URL_SECRET, "https://relay.example.test"], | |
| 360 | ]); | |
| 361 | const applyConfigCalls: Array<unknown> = []; | |
| 362 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 363 | ||
| 364 | return Effect.gen(function* () { | |
| 365 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 366 | ||
| 367 | expect(released).toBe(false); | |
| 368 | expect(applyConfigCalls).toEqual([]); | |
| 369 | expect(requests).toEqual([]); | |
| 370 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); | |
| 371 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | |
| 372 | }); | |
| 373 | ||
| 374 | it.effect("leaves the tunnel of a publish-only desired link untouched", () => { | |
| 375 | const { store, values } = makeMemorySecretStore([ | |
| 376 | [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], | |
| 377 | [RELAY_URL_SECRET, "https://relay.example.test"], | |
| 378 | [CLOUD_CLI_DESIRED_LINK_SECRET, "publish_only"], | |
| 379 | ]); | |
| 380 | const applyConfigCalls: Array<unknown> = []; | |
| 381 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 382 | ||
| 383 | return Effect.gen(function* () { | |
| 384 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 385 | ||
| 386 | expect(released).toBe(false); | |
| 387 | expect(applyConfigCalls).toEqual([]); | |
| 388 | expect(requests).toEqual([]); | |
| 389 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); | |
| 390 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | |
| 391 | }); | |
| 392 | ||
| 393 | it.effect("keeps a runtime config that a fast restart replaced mid-release", () => { | |
| 394 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); | |
| 395 | const applyConfigCalls: Array<unknown> = []; | |
| 396 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 397 | const freshConfig = new TextEncoder().encode("fresh-runtime-config"); | |
| 398 | ||
| 399 | return Effect.gen(function* () { | |
| 400 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 401 | ||
| 402 | expect(released).toBe(true); | |
| 403 | // The finalizer only drops the config it released; the one written by | |
| 404 | // the restarted process while the DELETE was in flight stays. | |
| 405 | expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig); | |
| 406 | }).pipe( | |
| 407 | provideReleaseHarness({ | |
| 408 | store, | |
| 409 | applyConfigCalls, | |
| 410 | requests, | |
| 411 | respond: () => { | |
| 412 | // A restarted process reconciled and stored a fresh connector config | |
| 413 | // while this shutdown's release request was in flight. | |
| 414 | values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig); | |
| 415 | return Response.json({ ok: true }); | |
| 416 | }, | |
| 417 | }), | |
| 418 | ); | |
| 419 | }); | |
| 420 | ||
| 421 | it.effect("keeps the stored connector token when the relay skipped the release", () => { | |
| 422 | // ok:false means a concurrent provision owns the recorded tunnel, so the | |
| 423 | // stored runtime config (possibly freshly written by that provision) must | |
| 424 | // survive. | |
| 425 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); | |
| 426 | const applyConfigCalls: Array<unknown> = []; | |
| 427 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 428 | ||
| 429 | return Effect.gen(function* () { | |
| 430 | const released = yield* releaseManagedTunnelOnShutdown(); | |
| 431 | ||
| 432 | expect(released).toBe(false); | |
| 433 | expect(requests).toHaveLength(1); | |
| 434 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); | |
| 435 | }).pipe( | |
| 436 | provideReleaseHarness({ | |
| 437 | store, | |
| 438 | applyConfigCalls, | |
| 439 | requests, | |
| 440 | respond: () => Response.json({ ok: false }), | |
| 441 | }), | |
| 442 | ); | |
| 443 | }); | |
| 444 | ||
| 445 | it.effect("keeps the stored connector token when the relay release request fails", () => { | |
| 446 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); | |
| 447 | const applyConfigCalls: Array<unknown> = []; | |
| 448 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | |
| 449 | ||
| 450 | return Effect.gen(function* () { | |
| 451 | const result = yield* Effect.result(releaseManagedTunnelOnShutdown()); | |
| 452 | ||
| 453 | expect(result._tag).toBe("Failure"); | |
| 454 | expect(requests).toHaveLength(1); | |
| 455 | // The tunnel still exists, so the stored token stays valid across the | |
| 456 | // restart and the next boot can bring the connector back immediately. | |
| 457 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); | |
| 458 | }).pipe( | |
| 459 | provideReleaseHarness({ | |
| 460 | store, | |
| 461 | applyConfigCalls, | |
| 462 | requests, | |
| 463 | respond: () => Response.json({ ok: false }, { status: 503 }), | |
| 464 | }), | |
| 465 | ); | |
| 466 | }); | |
| 467 | }); | |
| 468 | ||
| 216 | 469 | describe("link proof provider kinds", () => { |
| 217 | 470 | const proofRequest = ( |
| 218 | 471 | providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], |
apps/server/src/cloud/http.ts+72-1| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | RelayEnvironmentLinkProofPayload, |
| 29 | 29 | RelayLinkProofRequest, |
| 30 | 30 | RelayManagedEndpointOrigin, |
| 31 | RelayOkResponse, | |
| 31 | 32 | } from "@t3tools/contracts/relay"; |
| 32 | 33 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 33 | 34 | import { |
| ... | ... | @@ -68,7 +69,11 @@ import { |
| 68 | 69 | RELAY_URL_SECRET, |
| 69 | 70 | } from "./config.ts"; |
| 70 | 71 | import { relayUrlConfig } from "./publicConfig.ts"; |
| 71 | import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts"; | |
| 72 | import { | |
| 73 | readCliDesiredCloudLink, | |
| 74 | readCliDesiredLinkMode, | |
| 75 | setCliDesiredCloudLink, | |
| 76 | } from "./CliState.ts"; | |
| 72 | 77 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 73 | 78 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 74 | 79 | import { traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | ... | @@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD |
| 622 | 627 | }, |
| 623 | 628 | ); |
| 624 | 629 | |
| 630 | // Cloudflare bills per provisioned tunnel, so an environment that goes offline | |
| 631 | // must not leave its tunnel behind. Releasing deletes only the tunnel — the | |
| 632 | // relay keeps the link and its hostname reservation, and the next startup's | |
| 633 | // link reconcile provisions a replacement tunnel under the same URL. | |
| 634 | export const releaseManagedTunnelOnShutdown = Effect.fn( | |
| 635 | "environment.cloud.releaseManagedTunnelOnShutdown", | |
| 636 | )(function* () { | |
| 637 | const dependencies = yield* cloudHttpDependencies; | |
| 638 | // Only a managed link stores a runtime config; publish-only links have no | |
| 639 | // tunnel to release. | |
| 640 | const runtimeConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); | |
| 641 | if (Option.isNone(runtimeConfig)) { | |
| 642 | return false; | |
| 643 | } | |
| 644 | // Only CLI-desired managed links release on shutdown, because the startup | |
| 645 | // reconcile that provisions the replacement tunnel only runs for them. A | |
| 646 | // link installed by a web/mobile client comes back after a restart by | |
| 647 | // reapplying the stored connector token — it has no boot-time re-provision | |
| 648 | // path — so its tunnel must survive the restart. (Unlink still deletes it.) | |
| 649 | if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") { | |
| 650 | return false; | |
| 651 | } | |
| 652 | const token = yield* dependencies.cliTokenManager.getExisting; | |
| 653 | if (Option.isNone(token)) { | |
| 654 | return false; | |
| 655 | } | |
| 656 | // The link belongs to the relay it was installed against, so target the | |
| 657 | // persisted URL: T3CODE_RELAY_URL may have changed since the link was made. | |
| 658 | const relayUrl = yield* dependencies.secrets.get(RELAY_URL_SECRET); | |
| 659 | if (Option.isNone(relayUrl)) { | |
| 660 | return false; | |
| 661 | } | |
| 662 | const environmentId = yield* dependencies.environment.getEnvironmentId; | |
| 663 | // Stop the local connector before the relay deletes the tunnel it serves. | |
| 664 | yield* dependencies.endpointRuntime.applyConfig(null); | |
| 665 | const response = yield* HttpClientRequest.delete( | |
| 666 | `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`, | |
| 667 | ).pipe( | |
| 668 | HttpClientRequest.bearerToken(token.value.accessToken), | |
| 669 | dependencies.httpClient.execute, | |
| 670 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 671 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), | |
| 672 | withRelayClientTracing, | |
| 673 | ); | |
| 674 | // ok:false means the relay skipped deletion because a concurrent provision | |
| 675 | // owns the recorded tunnel now — leave the stored config alone. | |
| 676 | if (!response.ok) { | |
| 677 | return false; | |
| 678 | } | |
| 679 | // The connector token died with the tunnel. Drop the stored config so the | |
| 680 | // next start waits for the link reconcile instead of respawning the relay | |
| 681 | // client with a dead token. Kept when the release request fails: the tunnel | |
| 682 | // still exists, so the stored token keeps working across the restart. | |
| 683 | // Only dropped while it is still the config this shutdown released — a fast | |
| 684 | // restart may already have reconciled and stored a fresh config for its | |
| 685 | // replacement tunnel, and that one must survive this finalizer. | |
| 686 | const storedConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); | |
| 687 | if ( | |
| 688 | Option.isSome(storedConfig) && | |
| 689 | bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) | |
| 690 | ) { | |
| 691 | yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); | |
| 692 | } | |
| 693 | return true; | |
| 694 | }); | |
| 695 | ||
| 625 | 696 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( |
| 626 | 697 | dependencies: CloudHttpDependencies, |
| 627 | 698 | ) { |
apps/server/src/server.ts+25-1| ... | ... | @@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 75 | 75 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 76 | 76 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 77 | 77 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 78 | import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; | |
| 78 | import { | |
| 79 | connectHttpApiLayer, | |
| 80 | reconcileDesiredCloudLink, | |
| 81 | releaseManagedTunnelOnShutdown, | |
| 82 | } from "./cloud/http.ts"; | |
| 79 | 83 | import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; |
| 80 | 84 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 81 | 85 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| ... | ... | @@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap( |
| 458 | 462 | const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( |
| 459 | 463 | Effect.gen(function* () { |
| 460 | 464 | if (!hasCloudPublicConfig) return; |
| 465 | // Idle Cloudflare tunnels are billed, so a stopping server releases its | |
| 466 | // tunnel; the persisted desired link brings one back — same hostname, | |
| 467 | // fresh tunnel — when the environment starts again. Registered even | |
| 468 | // when no link is desired yet: a client can link a running server, and | |
| 469 | // that tunnel needs the same disposal on shutdown. | |
| 470 | yield* Effect.addFinalizer(() => | |
| 471 | releaseManagedTunnelOnShutdown().pipe( | |
| 472 | Effect.timeout("10 seconds"), | |
| 473 | Effect.tap((released) => | |
| 474 | released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, | |
| 475 | ), | |
| 476 | Effect.catchCause((cause) => | |
| 477 | Effect.logWarning( | |
| 478 | "Failed to release the managed tunnel on shutdown; the next link reuses it", | |
| 479 | { cause }, | |
| 480 | ), | |
| 481 | ), | |
| 482 | Effect.asVoid, | |
| 483 | ), | |
| 484 | ); | |
| 461 | 485 | if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; |
| 462 | 486 | const server = yield* HttpServer.HttpServer; |
| 463 | 487 | const address = server.address; |
infra/relay/src/environments/EnvironmentConnector.test.ts+3| ... | ... | @@ -188,6 +188,7 @@ function makeAllocations( |
| 188 | 188 | tunnelName: "tunnel-name", |
| 189 | 189 | dnsRecordId: "dns-record-id", |
| 190 | 190 | readyAt: "2026-05-25T00:00:00.000Z", |
| 191 | updatedAt: "2026-05-25T00:00:00.000Z", | |
| 191 | 192 | }, |
| 192 | 193 | ): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { |
| 193 | 194 | return { |
| ... | ... | @@ -196,6 +197,7 @@ function makeAllocations( |
| 196 | 197 | recordTunnel: () => Effect.die("unused"), |
| 197 | 198 | recordDns: () => Effect.die("unused"), |
| 198 | 199 | markReady: () => Effect.die("unused"), |
| 200 | claimRelease: () => Effect.die("unused"), | |
| 199 | 201 | remove: () => Effect.die("unused"), |
| 200 | 202 | }; |
| 201 | 203 | } |
| ... | ... | @@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => { |
| 467 | 469 | tunnelName: "tunnel-name", |
| 468 | 470 | dnsRecordId: "dns-record-id", |
| 469 | 471 | readyAt: null, |
| 472 | updatedAt: "2026-05-25T00:00:00.000Z", | |
| 470 | 473 | }), |
| 471 | 474 | }), |
| 472 | 475 | ), |
infra/relay/src/environments/EnvironmentLinker.test.ts+1| ... | ... | @@ -137,6 +137,7 @@ function testLayer(input?: { |
| 137 | 137 | }), |
| 138 | 138 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { |
| 139 | 139 | deprovision: input?.deprovision ?? (() => Effect.void), |
| 140 | release: () => Effect.succeed(true), | |
| 140 | 141 | provision: () => |
| 141 | 142 | Effect.succeed({ |
| 142 | 143 | endpoint: { |
infra/relay/src/environments/ManagedEndpointAllocations.ts+54| ... | ... | @@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation { |
| 18 | 18 | readonly tunnelName: string; |
| 19 | 19 | readonly dnsRecordId: string | null; |
| 20 | 20 | readonly readyAt: string | null; |
| 21 | /** | |
| 22 | * Doubles as the allocation's generation marker: every mutation rewrites it, | |
| 23 | * so `claimRelease` can detect a provision that raced a release. | |
| 24 | */ | |
| 25 | readonly updatedAt: string; | |
| 21 | 26 | } |
| 22 | 27 | |
| 23 | 28 | export function resolveReadyManagedEndpoint(input: { |
| ... | ... | @@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro |
| 45 | 50 | "record-tunnel", |
| 46 | 51 | "record-dns", |
| 47 | 52 | "mark-ready", |
| 53 | "claim-release", | |
| 48 | 54 | "remove", |
| 49 | 55 | ]), |
| 50 | 56 | stage: Schema.Literals(["database-request", "resolve-reservation"]), |
| ... | ... | @@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { |
| 80 | 86 | readonly dnsRecordId: string; |
| 81 | 87 | } |
| 82 | 88 | |
| 89 | interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey { | |
| 90 | readonly tunnelId: string; | |
| 91 | readonly updatedAt: string; | |
| 92 | } | |
| 93 | ||
| 83 | 94 | export class ManagedEndpointAllocations extends Context.Service< |
| 84 | 95 | ManagedEndpointAllocations, |
| 85 | 96 | { |
| ... | ... | @@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service< |
| 98 | 109 | readonly markReady: ( |
| 99 | 110 | input: ManagedEndpointAllocationKey, |
| 100 | 111 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; |
| 112 | /** | |
| 113 | * Atomically claims the right to delete the allocation's tunnel: succeeds | |
| 114 | * only while the recorded tunnel and generation still match what the | |
| 115 | * caller loaded. A concurrent provision rewrites `updatedAt` when it | |
| 116 | * records its tunnel, which makes a stale claim fail and keeps the freshly | |
| 117 | * issued tunnel alive. | |
| 118 | */ | |
| 119 | readonly claimRelease: ( | |
| 120 | input: ClaimManagedEndpointReleaseInput, | |
| 121 | ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>; | |
| 101 | 122 | readonly remove: ( |
| 102 | 123 | input: ManagedEndpointAllocationKey, |
| 103 | 124 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; |
| ... | ... | @@ -112,6 +133,7 @@ const allocationSelection = { |
| 112 | 133 | tunnelName: relayManagedEndpointAllocations.tunnelName, |
| 113 | 134 | dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, |
| 114 | 135 | readyAt: relayManagedEndpointAllocations.readyAt, |
| 136 | updatedAt: relayManagedEndpointAllocations.updatedAt, | |
| 115 | 137 | }; |
| 116 | 138 | |
| 117 | 139 | const whereAllocation = (input: ManagedEndpointAllocationKey) => |
| ... | ... | @@ -267,6 +289,38 @@ export const make = Effect.gen(function* () { |
| 267 | 289 | ), |
| 268 | 290 | ); |
| 269 | 291 | }), |
| 292 | claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* ( | |
| 293 | input: ClaimManagedEndpointReleaseInput, | |
| 294 | ) { | |
| 295 | const claimed = yield* db | |
| 296 | .update(relayManagedEndpointAllocations) | |
| 297 | .set({ | |
| 298 | updatedAt: DateTime.formatIso(yield* DateTime.now), | |
| 299 | }) | |
| 300 | .where( | |
| 301 | and( | |
| 302 | whereAllocation(input), | |
| 303 | eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId), | |
| 304 | eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), | |
| 305 | ), | |
| 306 | ) | |
| 307 | .returning({ userId: relayManagedEndpointAllocations.userId }) | |
| 308 | .pipe( | |
| 309 | Effect.map((rows) => rows.length > 0), | |
| 310 | Effect.mapError( | |
| 311 | (cause) => | |
| 312 | new ManagedEndpointAllocationPersistenceError({ | |
| 313 | operation: "claim-release", | |
| 314 | stage: "database-request", | |
| 315 | userId: input.userId, | |
| 316 | environmentId: input.environmentId, | |
| 317 | tunnelId: input.tunnelId, | |
| 318 | cause, | |
| 319 | }), | |
| 320 | ), | |
| 321 | ); | |
| 322 | return claimed; | |
| 323 | }), | |
| 270 | 324 | remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( |
| 271 | 325 | input: ManagedEndpointAllocationKey, |
| 272 | 326 | ) { |
infra/relay/src/environments/ManagedEndpointProvider.test.ts+198-14| ... | ... | @@ -41,7 +41,14 @@ interface DnsCall { |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | interface AllocationCall { |
| 44 | readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove"; | |
| 44 | readonly operation: | |
| 45 | | "get" | |
| 46 | | "reserve" | |
| 47 | | "recordTunnel" | |
| 48 | | "recordDns" | |
| 49 | | "markReady" | |
| 50 | | "claimRelease" | |
| 51 | | "remove"; | |
| 45 | 52 | readonly input: unknown; |
| 46 | 53 | } |
| 47 | 54 | |
| ... | ... | @@ -148,6 +155,18 @@ function makeDnsClient( |
| 148 | 155 | |
| 149 | 156 | function makeAllocations(calls: AllocationCall[] = []) { |
| 150 | 157 | const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>(); |
| 158 | let generation = 0; | |
| 159 | const mutate = ( | |
| 160 | key: string, | |
| 161 | change: ( | |
| 162 | allocation: ManagedEndpointAllocations.ManagedEndpointAllocation, | |
| 163 | ) => ManagedEndpointAllocations.ManagedEndpointAllocation, | |
| 164 | ) => { | |
| 165 | const allocation = allocations.get(key); | |
| 166 | if (allocation !== undefined) { | |
| 167 | allocations.set(key, { ...change(allocation), updatedAt: `generation-${++generation}` }); | |
| 168 | } | |
| 169 | }; | |
| 151 | 170 | return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 152 | 171 | get: (input) => |
| 153 | 172 | Effect.sync(() => { |
| ... | ... | @@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) { |
| 162 | 181 | tunnelId: null, |
| 163 | 182 | dnsRecordId: null, |
| 164 | 183 | readyAt: null, |
| 184 | updatedAt: `generation-${++generation}`, | |
| 165 | 185 | }; |
| 166 | 186 | allocations.set(allocationKey(input), allocation); |
| 167 | 187 | return allocation; |
| ... | ... | @@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) { |
| 169 | 189 | recordTunnel: (input) => |
| 170 | 190 | Effect.sync(() => { |
| 171 | 191 | calls.push({ operation: "recordTunnel", input }); |
| 172 | const allocation = allocations.get(allocationKey(input)); | |
| 173 | if (allocation !== undefined) { | |
| 174 | allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); | |
| 175 | } | |
| 192 | mutate(allocationKey(input), (allocation) => ({ | |
| 193 | ...allocation, | |
| 194 | tunnelId: input.tunnelId, | |
| 195 | })); | |
| 176 | 196 | }), |
| 177 | 197 | recordDns: (input) => |
| 178 | 198 | Effect.sync(() => { |
| 179 | 199 | calls.push({ operation: "recordDns", input }); |
| 180 | const allocation = allocations.get(allocationKey(input)); | |
| 181 | if (allocation !== undefined) { | |
| 182 | allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId }); | |
| 183 | } | |
| 200 | mutate(allocationKey(input), (allocation) => ({ | |
| 201 | ...allocation, | |
| 202 | dnsRecordId: input.dnsRecordId, | |
| 203 | })); | |
| 184 | 204 | }), |
| 185 | 205 | markReady: (input) => |
| 186 | 206 | Effect.sync(() => { |
| 187 | 207 | calls.push({ operation: "markReady", input }); |
| 208 | mutate(allocationKey(input), (allocation) => ({ | |
| 209 | ...allocation, | |
| 210 | readyAt: "2026-06-02T00:00:00.000Z", | |
| 211 | })); | |
| 212 | }), | |
| 213 | claimRelease: (input) => | |
| 214 | Effect.sync(() => { | |
| 215 | calls.push({ operation: "claimRelease", input }); | |
| 188 | 216 | const allocation = allocations.get(allocationKey(input)); |
| 189 | if (allocation !== undefined) { | |
| 190 | allocations.set(allocationKey(input), { | |
| 191 | ...allocation, | |
| 192 | readyAt: "2026-06-02T00:00:00.000Z", | |
| 193 | }); | |
| 217 | if ( | |
| 218 | allocation === undefined || | |
| 219 | allocation.tunnelId !== input.tunnelId || | |
| 220 | allocation.updatedAt !== input.updatedAt | |
| 221 | ) { | |
| 222 | return false; | |
| 194 | 223 | } |
| 224 | mutate(allocationKey(input), (current) => current); | |
| 225 | return true; | |
| 195 | 226 | }), |
| 196 | 227 | remove: (input) => |
| 197 | 228 | Effect.sync(() => { |
| ... | ... | @@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => { |
| 706 | 737 | }, |
| 707 | 738 | ); |
| 708 | 739 | |
| 740 | it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => { | |
| 741 | const tunnelCalls: TunnelCall[] = []; | |
| 742 | const dnsCalls: DnsCall[] = []; | |
| 743 | const allocationCalls: AllocationCall[] = []; | |
| 744 | const layer = providerLayer( | |
| 745 | makePersistentTunnelClient(tunnelCalls), | |
| 746 | makeDnsClient(dnsCalls), | |
| 747 | makeAllocations(allocationCalls), | |
| 748 | ); | |
| 749 | ||
| 750 | return Effect.gen(function* () { | |
| 751 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | |
| 752 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | |
| 753 | const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; | |
| 754 | const first = yield* provider.provision({ ...key, origin }); | |
| 755 | const released = yield* provider.release(key); | |
| 756 | const second = yield* provider.provision({ ...key, origin }); | |
| 757 | ||
| 758 | expect(released).toBe(true); | |
| 759 | expect(second.endpoint).toEqual(first.endpoint); | |
| 760 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ | |
| 761 | // first provision | |
| 762 | "list", | |
| 763 | "create", | |
| 764 | "putConfiguration", | |
| 765 | "getToken", | |
| 766 | // release deletes only the tunnel... | |
| 767 | "delete", | |
| 768 | // ...and the next provision recreates it under the same name | |
| 769 | "list", | |
| 770 | "create", | |
| 771 | "putConfiguration", | |
| 772 | "getToken", | |
| 773 | ]); | |
| 774 | // The DNS record survives the release and is repointed, never deleted. | |
| 775 | expect(dnsCalls.map((call) => call.operation)).toEqual([ | |
| 776 | "listRecords", | |
| 777 | "createRecord", | |
| 778 | "updateRecord", | |
| 779 | ]); | |
| 780 | expect(allocationCalls.map((call) => call.operation)).not.toContain("remove"); | |
| 781 | }).pipe(Effect.provide(layer)); | |
| 782 | }); | |
| 783 | ||
| 784 | it.effect("treats an environment without a recorded tunnel as already released", () => { | |
| 785 | const tunnelCalls: TunnelCall[] = []; | |
| 786 | const dnsCalls: DnsCall[] = []; | |
| 787 | const layer = providerLayer( | |
| 788 | makePersistentTunnelClient(tunnelCalls), | |
| 789 | makeDnsClient(dnsCalls), | |
| 790 | makeAllocations(), | |
| 791 | ); | |
| 792 | ||
| 793 | return Effect.gen(function* () { | |
| 794 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | |
| 795 | const released = yield* provider.release({ userId: "user_ABC", environmentId: "env_ABC" }); | |
| 796 | ||
| 797 | expect(released).toBe(true); | |
| 798 | expect(tunnelCalls).toEqual([]); | |
| 799 | expect(dnsCalls).toEqual([]); | |
| 800 | }).pipe(Effect.provide(layer)); | |
| 801 | }); | |
| 802 | ||
| 803 | it.effect("keeps the tunnel alive when a concurrent provision outdates the release claim", () => { | |
| 804 | const tunnelCalls: TunnelCall[] = []; | |
| 805 | const allocations = makeAllocations(); | |
| 806 | // Simulates a provision racing the release: the allocation generation no | |
| 807 | // longer matches what the release loaded, so the claim fails. | |
| 808 | const outdated = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ | |
| 809 | ...allocations, | |
| 810 | claimRelease: () => Effect.succeed(false), | |
| 811 | }); | |
| 812 | const layer = providerLayer(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), outdated); | |
| 813 | ||
| 814 | return Effect.gen(function* () { | |
| 815 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | |
| 816 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | |
| 817 | yield* provider.provision({ | |
| 818 | ...key, | |
| 819 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | |
| 820 | }); | |
| 821 | const released = yield* provider.release(key); | |
| 822 | ||
| 823 | // false tells the caller its connector token is still live, so it must | |
| 824 | // keep its runtime config. | |
| 825 | expect(released).toBe(false); | |
| 826 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ | |
| 827 | "list", | |
| 828 | "create", | |
| 829 | "putConfiguration", | |
| 830 | "getToken", | |
| 831 | ]); | |
| 832 | }).pipe(Effect.provide(layer)); | |
| 833 | }); | |
| 834 | ||
| 835 | it.effect("treats an already deleted tunnel as successfully released", () => { | |
| 836 | const notFound = { _tag: "NotFound" } as const; | |
| 837 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | |
| 838 | ...makeTunnelClient(), | |
| 839 | delete: (tunnelId) => | |
| 840 | Effect.fail( | |
| 841 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ | |
| 842 | operation: "delete", | |
| 843 | tunnelId, | |
| 844 | cause: notFound, | |
| 845 | }), | |
| 846 | ), | |
| 847 | }); | |
| 848 | const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); | |
| 849 | ||
| 850 | return Effect.gen(function* () { | |
| 851 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | |
| 852 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | |
| 853 | yield* provider.provision({ | |
| 854 | ...key, | |
| 855 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | |
| 856 | }); | |
| 857 | yield* provider.release(key); | |
| 858 | }).pipe(Effect.provide(layer)); | |
| 859 | }); | |
| 860 | ||
| 861 | it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => { | |
| 862 | const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ | |
| 863 | operation: "delete", | |
| 864 | tunnelId: "tunnel-id", | |
| 865 | cause: "Cloudflare tunnel deletion failed", | |
| 866 | }); | |
| 867 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | |
| 868 | ...makeTunnelClient(), | |
| 869 | delete: () => Effect.fail(failure), | |
| 870 | }); | |
| 871 | const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); | |
| 872 | ||
| 873 | return Effect.gen(function* () { | |
| 874 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | |
| 875 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | |
| 876 | yield* provider.provision({ | |
| 877 | ...key, | |
| 878 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | |
| 879 | }); | |
| 880 | const error = yield* Effect.flip(provider.release(key)); | |
| 881 | ||
| 882 | expect(error).toMatchObject({ | |
| 883 | _tag: "ManagedEndpointDeprovisioningFailed", | |
| 884 | stage: "delete-tunnel", | |
| 885 | userId: key.userId, | |
| 886 | environmentId: key.environmentId, | |
| 887 | tunnelId: "tunnel-id", | |
| 888 | }); | |
| 889 | expect(error.cause).toBe(failure); | |
| 890 | }).pipe(Effect.provide(layer)); | |
| 891 | }); | |
| 892 | ||
| 709 | 893 | it.effect("treats an absent allocation as already deprovisioned", () => { |
| 710 | 894 | const tunnelCalls: TunnelCall[] = []; |
| 711 | 895 | const dnsCalls: DnsCall[] = []; |
infra/relay/src/environments/ManagedEndpointProvider.ts+83| ... | ... | @@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M |
| 76 | 76 | |
| 77 | 77 | const ManagedEndpointDeprovisioningStage = Schema.Literals([ |
| 78 | 78 | "load-allocation", |
| 79 | "claim-release", | |
| 79 | 80 | "delete-dns-record", |
| 80 | 81 | "delete-tunnel", |
| 81 | 82 | "remove-allocation", |
| ... | ... | @@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service< |
| 134 | 135 | readonly userId: string; |
| 135 | 136 | readonly environmentId: string; |
| 136 | 137 | }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>; |
| 138 | /** | |
| 139 | * Deletes the provisioned Cloudflare tunnel while keeping the allocation | |
| 140 | * (hostname + tunnel name reservation) and DNS record. Cloudflare bills per | |
| 141 | * provisioned tunnel, so environments release the tunnel when they shut | |
| 142 | * down; the next `provision` recreates it under the same name and repoints | |
| 143 | * the CNAME, preserving the endpoint URL. | |
| 144 | * | |
| 145 | * Resolves to whether the caller's connector token is now dead: true when | |
| 146 | * the tunnel was deleted (or none was recorded to begin with), false when | |
| 147 | * a concurrent provision outbid the release claim and the recorded tunnel | |
| 148 | * — and any token issued for it — stays live. | |
| 149 | */ | |
| 150 | readonly release: (input: { | |
| 151 | readonly userId: string; | |
| 152 | readonly environmentId: string; | |
| 153 | }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>; | |
| 137 | 154 | } |
| 138 | 155 | >()("t3code-relay/environments/ManagedEndpointProvider") {} |
| 139 | 156 | |
| ... | ... | @@ -467,6 +484,72 @@ export const make = Effect.gen(function* () { |
| 467 | 484 | ), |
| 468 | 485 | ); |
| 469 | 486 | }), |
| 487 | release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) { | |
| 488 | yield* Effect.annotateCurrentSpan({ | |
| 489 | "relay.user_id": input.userId, | |
| 490 | "relay.environment_id": input.environmentId, | |
| 491 | }); | |
| 492 | const allocation = yield* allocations.get(input).pipe( | |
| 493 | Effect.mapError( | |
| 494 | (cause) => | |
| 495 | new ManagedEndpointDeprovisioningFailed({ | |
| 496 | ...input, | |
| 497 | stage: "load-allocation", | |
| 498 | cause, | |
| 499 | }), | |
| 500 | ), | |
| 501 | ); | |
| 502 | const tunnelId = allocation?.tunnelId ?? null; | |
| 503 | if (allocation === null || tunnelId === null) { | |
| 504 | return true; | |
| 505 | } | |
| 506 | // Claim the release against the allocation's current generation before | |
| 507 | // touching Cloudflare. A provision racing this release (fast environment | |
| 508 | // restart) rewrites updatedAt when it records its tunnel, so a stale | |
| 509 | // claim means the recorded tunnel may already back a fresh connector and | |
| 510 | // must be left alive. A provision that starts after the claim instead | |
| 511 | // fails loudly on the deleted tunnel and the client-side retry | |
| 512 | // provisions a replacement. | |
| 513 | const claimed = yield* allocations | |
| 514 | .claimRelease({ | |
| 515 | userId: input.userId, | |
| 516 | environmentId: input.environmentId, | |
| 517 | tunnelId, | |
| 518 | updatedAt: allocation.updatedAt, | |
| 519 | }) | |
| 520 | .pipe( | |
| 521 | Effect.mapError( | |
| 522 | (cause) => | |
| 523 | new ManagedEndpointDeprovisioningFailed({ | |
| 524 | ...input, | |
| 525 | stage: "claim-release", | |
| 526 | tunnelId, | |
| 527 | cause, | |
| 528 | }), | |
| 529 | ), | |
| 530 | ); | |
| 531 | if (!claimed) { | |
| 532 | return false; | |
| 533 | } | |
| 534 | yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe( | |
| 535 | Effect.mapError( | |
| 536 | (cause) => | |
| 537 | new ManagedEndpointDeprovisioningFailed({ | |
| 538 | ...input, | |
| 539 | stage: "delete-tunnel", | |
| 540 | tunnelId, | |
| 541 | cause, | |
| 542 | }), | |
| 543 | ), | |
| 544 | ); | |
| 545 | // The recorded tunnelId is now stale, but the allocation row is left | |
| 546 | // untouched deliberately: connect/status authorization requires a fully | |
| 547 | // recorded allocation, and an offline environment must keep reporting | |
| 548 | // "offline" (health probe fails) rather than "not authorized". The next | |
| 549 | // provision lists tunnels by name, finds none, creates a replacement and | |
| 550 | // re-records the fresh id. | |
| 551 | return true; | |
| 552 | }), | |
| 470 | 553 | provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { |
| 471 | 554 | yield* Effect.annotateCurrentSpan({ |
| 472 | 555 | "relay.user_id": input.userId, |
infra/relay/src/http/Api.ts+17| ... | ... | @@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group( |
| 617 | 617 | } |
| 618 | 618 | return { ok: unlinked }; |
| 619 | 619 | }, mapRelayCommonApiErrors("not_authorized")), |
| 620 | ) | |
| 621 | .handle( | |
| 622 | "releaseEnvironmentTunnel", | |
| 623 | Effect.fn("relay.api.client.releaseEnvironmentTunnel")(function* (args) { | |
| 624 | const { params } = args; | |
| 625 | const { userId } = yield* RelayClientPrincipal; | |
| 626 | // ok mirrors whether the connector token is now dead: false means a | |
| 627 | // concurrent provision kept the recorded tunnel alive, so the caller | |
| 628 | // must not discard its runtime config. | |
| 629 | const released = yield* managedEndpointProvider | |
| 630 | .release({ | |
| 631 | userId, | |
| 632 | environmentId: params.environmentId, | |
| 633 | }) | |
| 634 | .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); | |
| 635 | return { ok: released }; | |
| 636 | }, mapRelayCommonApiErrors("not_authorized")), | |
| 620 | 637 | ); |
| 621 | 638 | }), |
| 622 | 639 | ); |
packages/contracts/src/relay.ts+15| ... | ... | @@ -962,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client") |
| 962 | 962 | success: RelayOkResponse, |
| 963 | 963 | error: RelayAuthAndInternalErrors, |
| 964 | 964 | }).annotate(OpenApi.Summary, "Unlink an environment"), |
| 965 | HttpApiEndpoint.delete( | |
| 966 | "releaseEnvironmentTunnel", | |
| 967 | "/v1/client/environment-links/:environmentId/tunnel", | |
| 968 | { | |
| 969 | headers: RelayBearerRequestHeaders, | |
| 970 | params: RelayEnvironmentUnlinkParams, | |
| 971 | success: RelayOkResponse, | |
| 972 | error: RelayAuthAndInternalErrors, | |
| 973 | }, | |
| 974 | ) | |
| 975 | .annotate(OpenApi.Summary, "Release an environment's managed tunnel") | |
| 976 | .annotate( | |
| 977 | OpenApi.Description, | |
| 978 | "Deletes the provisioned Cloudflare tunnel while keeping the environment link and its hostname reservation, so a later link re-provisions the tunnel under the same URL. Environments call this when they shut down; Cloudflare bills per provisioned tunnel, so idle tunnels should not outlive their environment.", | |
| 979 | ), | |
| 965 | 980 | ) |
| 966 | 981 | .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") |
| 967 | 982 | .middleware(RelayClientAuth); |