| 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,19 +4,28 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as PlatformError from "effect/PlatformError"; | 5 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Tracer from "effect/Tracer"; | 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 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | 15 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 16 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; | ||
| 13 | import * as CliTokenManager from "./CliTokenManager.ts"; | 20 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; | 21 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; |
| 22 | import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts"; | ||
| 15 | import { | 23 | import { |
| 16 | consumeCloudReplayGuards, | 24 | consumeCloudReplayGuards, |
| 17 | isSupportedLinkProviderKind, | 25 | isSupportedLinkProviderKind, |
| 18 | linkProofScopes, | 26 | linkProofScopes, |
| 19 | reconcileDesiredCloudLink, | 27 | reconcileDesiredCloudLink, |
| 28 | releaseManagedTunnelOnShutdown, | ||
| 20 | } from "./http.ts"; | 29 | } from "./http.ts"; |
| 21 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 30 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 22 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; | 31 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | @@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => { | ... | @@ -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 | describe("link proof provider kinds", () => { | 469 | describe("link proof provider kinds", () => { |
| 217 | const proofRequest = ( | 470 | const proofRequest = ( |
| 218 | providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], | 471 | providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], |
apps/server/src/cloud/http.ts+72-1| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | RelayEnvironmentLinkProofPayload, | 28 | RelayEnvironmentLinkProofPayload, |
| 29 | RelayLinkProofRequest, | 29 | RelayLinkProofRequest, |
| 30 | RelayManagedEndpointOrigin, | 30 | RelayManagedEndpointOrigin, |
| 31 | RelayOkResponse, | ||
| 31 | } from "@t3tools/contracts/relay"; | 32 | } from "@t3tools/contracts/relay"; |
| 32 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 33 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 33 | import { | 34 | import { |
| ... | @@ -68,7 +69,11 @@ import { | ... | @@ -68,7 +69,11 @@ import { |
| 68 | RELAY_URL_SECRET, | 69 | RELAY_URL_SECRET, |
| 69 | } from "./config.ts"; | 70 | } from "./config.ts"; |
| 70 | import { relayUrlConfig } from "./publicConfig.ts"; | 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 | import * as CliTokenManager from "./CliTokenManager.ts"; | 77 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 73 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 78 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 74 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | 79 | import { traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | @@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD | ... | @@ -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 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( | 696 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( |
| 626 | dependencies: CloudHttpDependencies, | 697 | dependencies: CloudHttpDependencies, |
| 627 | ) { | 698 | ) { |
apps/server/src/server.ts+25-1| ... | @@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | ... | @@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 75 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 75 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 76 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 76 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 77 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 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 | import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; | 83 | import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; |
| 80 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | 84 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 81 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | 85 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| ... | @@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap( |
| 458 | const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( | 462 | const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( |
| 459 | Effect.gen(function* () { | 463 | Effect.gen(function* () { |
| 460 | if (!hasCloudPublicConfig) return; | 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 | if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; | 485 | if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; |
| 462 | const server = yield* HttpServer.HttpServer; | 486 | const server = yield* HttpServer.HttpServer; |
| 463 | const address = server.address; | 487 | const address = server.address; |
infra/relay/src/environments/EnvironmentConnector.test.ts+3| ... | @@ -188,6 +188,7 @@ function makeAllocations( | ... | @@ -188,6 +188,7 @@ function makeAllocations( |
| 188 | tunnelName: "tunnel-name", | 188 | tunnelName: "tunnel-name", |
| 189 | dnsRecordId: "dns-record-id", | 189 | dnsRecordId: "dns-record-id", |
| 190 | readyAt: "2026-05-25T00:00:00.000Z", | 190 | readyAt: "2026-05-25T00:00:00.000Z", |
| 191 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 191 | }, | 192 | }, |
| 192 | ): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { | 193 | ): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { |
| 193 | return { | 194 | return { |
| ... | @@ -196,6 +197,7 @@ function makeAllocations( | ... | @@ -196,6 +197,7 @@ function makeAllocations( |
| 196 | recordTunnel: () => Effect.die("unused"), | 197 | recordTunnel: () => Effect.die("unused"), |
| 197 | recordDns: () => Effect.die("unused"), | 198 | recordDns: () => Effect.die("unused"), |
| 198 | markReady: () => Effect.die("unused"), | 199 | markReady: () => Effect.die("unused"), |
| 200 | claimRelease: () => Effect.die("unused"), | ||
| 199 | remove: () => Effect.die("unused"), | 201 | remove: () => Effect.die("unused"), |
| 200 | }; | 202 | }; |
| 201 | } | 203 | } |
| ... | @@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => { | ... | @@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => { |
| 467 | tunnelName: "tunnel-name", | 469 | tunnelName: "tunnel-name", |
| 468 | dnsRecordId: "dns-record-id", | 470 | dnsRecordId: "dns-record-id", |
| 469 | readyAt: null, | 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,6 +137,7 @@ function testLayer(input?: { |
| 137 | }), | 137 | }), |
| 138 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { | 138 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { |
| 139 | deprovision: input?.deprovision ?? (() => Effect.void), | 139 | deprovision: input?.deprovision ?? (() => Effect.void), |
| 140 | release: () => Effect.succeed(true), | ||
| 140 | provision: () => | 141 | provision: () => |
| 141 | Effect.succeed({ | 142 | Effect.succeed({ |
| 142 | endpoint: { | 143 | endpoint: { |
infra/relay/src/environments/ManagedEndpointAllocations.ts+54| ... | @@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation { | ... | @@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation { |
| 18 | readonly tunnelName: string; | 18 | readonly tunnelName: string; |
| 19 | readonly dnsRecordId: string | null; | 19 | readonly dnsRecordId: string | null; |
| 20 | readonly readyAt: string | null; | 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 | export function resolveReadyManagedEndpoint(input: { | 28 | export function resolveReadyManagedEndpoint(input: { |
| ... | @@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro | ... | @@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro |
| 45 | "record-tunnel", | 50 | "record-tunnel", |
| 46 | "record-dns", | 51 | "record-dns", |
| 47 | "mark-ready", | 52 | "mark-ready", |
| 53 | "claim-release", | ||
| 48 | "remove", | 54 | "remove", |
| 49 | ]), | 55 | ]), |
| 50 | stage: Schema.Literals(["database-request", "resolve-reservation"]), | 56 | stage: Schema.Literals(["database-request", "resolve-reservation"]), |
| ... | @@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { | ... | @@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { |
| 80 | readonly dnsRecordId: string; | 86 | readonly dnsRecordId: string; |
| 81 | } | 87 | } |
| 82 | 88 | ||
| 89 | interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey { | ||
| 90 | readonly tunnelId: string; | ||
| 91 | readonly updatedAt: string; | ||
| 92 | } | ||
| 93 | |||
| 83 | export class ManagedEndpointAllocations extends Context.Service< | 94 | export class ManagedEndpointAllocations extends Context.Service< |
| 84 | ManagedEndpointAllocations, | 95 | ManagedEndpointAllocations, |
| 85 | { | 96 | { |
| ... | @@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service< | ... | @@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service< |
| 98 | readonly markReady: ( | 109 | readonly markReady: ( |
| 99 | input: ManagedEndpointAllocationKey, | 110 | input: ManagedEndpointAllocationKey, |
| 100 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | 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 | readonly remove: ( | 122 | readonly remove: ( |
| 102 | input: ManagedEndpointAllocationKey, | 123 | input: ManagedEndpointAllocationKey, |
| 103 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | 124 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; |
| ... | @@ -112,6 +133,7 @@ const allocationSelection = { | ... | @@ -112,6 +133,7 @@ const allocationSelection = { |
| 112 | tunnelName: relayManagedEndpointAllocations.tunnelName, | 133 | tunnelName: relayManagedEndpointAllocations.tunnelName, |
| 113 | dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, | 134 | dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, |
| 114 | readyAt: relayManagedEndpointAllocations.readyAt, | 135 | readyAt: relayManagedEndpointAllocations.readyAt, |
| 136 | updatedAt: relayManagedEndpointAllocations.updatedAt, | ||
| 115 | }; | 137 | }; |
| 116 | 138 | ||
| 117 | const whereAllocation = (input: ManagedEndpointAllocationKey) => | 139 | const whereAllocation = (input: ManagedEndpointAllocationKey) => |
| ... | @@ -267,6 +289,38 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( | 324 | remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( |
| 271 | input: ManagedEndpointAllocationKey, | 325 | input: ManagedEndpointAllocationKey, |
| 272 | ) { | 326 | ) { |
infra/relay/src/environments/ManagedEndpointProvider.test.ts+198-14| ... | @@ -41,7 +41,14 @@ interface DnsCall { | ... | @@ -41,7 +41,14 @@ interface DnsCall { |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | interface AllocationCall { | 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 | readonly input: unknown; | 52 | readonly input: unknown; |
| 46 | } | 53 | } |
| 47 | 54 | ||
| ... | @@ -148,6 +155,18 @@ function makeDnsClient( | ... | @@ -148,6 +155,18 @@ function makeDnsClient( |
| 148 | 155 | ||
| 149 | function makeAllocations(calls: AllocationCall[] = []) { | 156 | function makeAllocations(calls: AllocationCall[] = []) { |
| 150 | const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>(); | 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 | return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ | 170 | return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ |
| 152 | get: (input) => | 171 | get: (input) => |
| 153 | Effect.sync(() => { | 172 | Effect.sync(() => { |
| ... | @@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) { | ... | @@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) { |
| 162 | tunnelId: null, | 181 | tunnelId: null, |
| 163 | dnsRecordId: null, | 182 | dnsRecordId: null, |
| 164 | readyAt: null, | 183 | readyAt: null, |
| 184 | updatedAt: `generation-${++generation}`, | ||
| 165 | }; | 185 | }; |
| 166 | allocations.set(allocationKey(input), allocation); | 186 | allocations.set(allocationKey(input), allocation); |
| 167 | return allocation; | 187 | return allocation; |
| ... | @@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) { | ... | @@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) { |
| 169 | recordTunnel: (input) => | 189 | recordTunnel: (input) => |
| 170 | Effect.sync(() => { | 190 | Effect.sync(() => { |
| 171 | calls.push({ operation: "recordTunnel", input }); | 191 | calls.push({ operation: "recordTunnel", input }); |
| 172 | const allocation = allocations.get(allocationKey(input)); | 192 | mutate(allocationKey(input), (allocation) => ({ |
| 173 | if (allocation !== undefined) { | 193 | ...allocation, |
| 174 | allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); | 194 | tunnelId: input.tunnelId, |
| 175 | } | 195 | })); |
| 176 | }), | 196 | }), |
| 177 | recordDns: (input) => | 197 | recordDns: (input) => |
| 178 | Effect.sync(() => { | 198 | Effect.sync(() => { |
| 179 | calls.push({ operation: "recordDns", input }); | 199 | calls.push({ operation: "recordDns", input }); |
| 180 | const allocation = allocations.get(allocationKey(input)); | 200 | mutate(allocationKey(input), (allocation) => ({ |
| 181 | if (allocation !== undefined) { | 201 | ...allocation, |
| 182 | allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId }); | 202 | dnsRecordId: input.dnsRecordId, |
| 183 | } | 203 | })); |
| 184 | }), | 204 | }), |
| 185 | markReady: (input) => | 205 | markReady: (input) => |
| 186 | Effect.sync(() => { | 206 | Effect.sync(() => { |
| 187 | calls.push({ operation: "markReady", input }); | 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 | const allocation = allocations.get(allocationKey(input)); | 216 | const allocation = allocations.get(allocationKey(input)); |
| 189 | if (allocation !== undefined) { | 217 | if ( |
| 190 | allocations.set(allocationKey(input), { | 218 | allocation === undefined || |
| 191 | ...allocation, | 219 | allocation.tunnelId !== input.tunnelId || |
| 192 | readyAt: "2026-06-02T00:00:00.000Z", | 220 | allocation.updatedAt !== input.updatedAt |
| 193 | }); | 221 | ) { |
| 222 | return false; | ||
| 194 | } | 223 | } |
| 224 | mutate(allocationKey(input), (current) => current); | ||
| 225 | return true; | ||
| 195 | }), | 226 | }), |
| 196 | remove: (input) => | 227 | remove: (input) => |
| 197 | Effect.sync(() => { | 228 | Effect.sync(() => { |
| ... | @@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => { | ... | @@ -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 | it.effect("treats an absent allocation as already deprovisioned", () => { | 893 | it.effect("treats an absent allocation as already deprovisioned", () => { |
| 710 | const tunnelCalls: TunnelCall[] = []; | 894 | const tunnelCalls: TunnelCall[] = []; |
| 711 | const dnsCalls: DnsCall[] = []; | 895 | const dnsCalls: DnsCall[] = []; |
infra/relay/src/environments/ManagedEndpointProvider.ts+83| ... | @@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M | ... | @@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M |
| 76 | 76 | ||
| 77 | const ManagedEndpointDeprovisioningStage = Schema.Literals([ | 77 | const ManagedEndpointDeprovisioningStage = Schema.Literals([ |
| 78 | "load-allocation", | 78 | "load-allocation", |
| 79 | "claim-release", | ||
| 79 | "delete-dns-record", | 80 | "delete-dns-record", |
| 80 | "delete-tunnel", | 81 | "delete-tunnel", |
| 81 | "remove-allocation", | 82 | "remove-allocation", |
| ... | @@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service< | ... | @@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service< |
| 134 | readonly userId: string; | 135 | readonly userId: string; |
| 135 | readonly environmentId: string; | 136 | readonly environmentId: string; |
| 136 | }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>; | 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 | >()("t3code-relay/environments/ManagedEndpointProvider") {} | 155 | >()("t3code-relay/environments/ManagedEndpointProvider") {} |
| 139 | 156 | ||
| ... | @@ -467,6 +484,72 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { | 553 | provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { |
| 471 | yield* Effect.annotateCurrentSpan({ | 554 | yield* Effect.annotateCurrentSpan({ |
| 472 | "relay.user_id": input.userId, | 555 | "relay.user_id": input.userId, |
infra/relay/src/http/Api.ts+17| ... | @@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group( | ... | @@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group( |
| 617 | } | 617 | } |
| 618 | return { ok: unlinked }; | 618 | return { ok: unlinked }; |
| 619 | }, mapRelayCommonApiErrors("not_authorized")), | 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,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client") |
| 962 | success: RelayOkResponse, | 962 | success: RelayOkResponse, |
| 963 | error: RelayAuthAndInternalErrors, | 963 | error: RelayAuthAndInternalErrors, |
| 964 | }).annotate(OpenApi.Summary, "Unlink an environment"), | 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 | .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") | 981 | .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") |
| 967 | .middleware(RelayClientAuth); | 982 | .middleware(RelayClientAuth); |