authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 17:33:41+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 17:33:41+02:00
log96398e3775fc6c74525e4a211e75e580c6cf4225
tree30c82c624ae9daa8b2edb5ebfcaf617ac64cc78d
parentd2d40b6c1aa2464b38357d52b7f991cfb422a669
signature Signed by PGP key B5690EEEBB952194

feat(connect): release the Cloudflare tunnel when the environment shuts down (#4531)

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";
44import * as Option from "effect/Option";
55import * as PlatformError from "effect/PlatformError";
66import * as Tracer from "effect/Tracer";
7import { HttpClient, HttpServerRequest } from "effect/unstable/http";
7import {
8 HttpClient,
9 HttpClientResponse,
10 HttpServerRequest,
11 type HttpClientRequest,
12} from "effect/unstable/http";
813
14import { EnvironmentId } from "@t3tools/contracts";
915import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1016import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1117import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1218import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
1320import * as CliTokenManager from "./CliTokenManager.ts";
1421import type { RelayLinkProofRequest } from "@t3tools/contracts/relay";
22import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts";
1523import {
1624 consumeCloudReplayGuards,
1725 isSupportedLinkProviderKind,
1826 linkProofScopes,
1927 reconcileDesiredCloudLink,
28 releaseManagedTunnelOnShutdown,
2029} from "./http.ts";
2130import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
2231import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
......@@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => {
213222 );
214223});
215224
225describe("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
216469describe("link proof provider kinds", () => {
217470 const proofRequest = (
218471 providerKind: RelayLinkProofRequest["endpoint"]["providerKind"],
apps/server/src/cloud/http.ts+72-1
......@@ -28,6 +28,7 @@ import {
2828 RelayEnvironmentLinkProofPayload,
2929 RelayLinkProofRequest,
3030 RelayManagedEndpointOrigin,
31 RelayOkResponse,
3132} from "@t3tools/contracts/relay";
3233import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3334import {
......@@ -68,7 +69,11 @@ import {
6869 RELAY_URL_SECRET,
6970} from "./config.ts";
7071import { relayUrlConfig } from "./publicConfig.ts";
71import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts";
72import {
73 readCliDesiredCloudLink,
74 readCliDesiredLinkMode,
75 setCliDesiredCloudLink,
76} from "./CliState.ts";
7277import * as CliTokenManager from "./CliTokenManager.ts";
7378import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
7479import { traceRelayRequest } from "./traceRelayRequest.ts";
......@@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
622627 },
623628);
624629
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.
634export 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
625696const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
626697 dependencies: CloudHttpDependencies,
627698) {
apps/server/src/server.ts+25-1
......@@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
7575import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
7676import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
7777import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
78import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
78import {
79 connectHttpApiLayer,
80 reconcileDesiredCloudLink,
81 releaseManagedTunnelOnShutdown,
82} from "./cloud/http.ts";
7983import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
8084import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
8185import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
......@@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap(
458462 const cloudDesiredLinkReconcileLayer = Layer.effectDiscard(
459463 Effect.gen(function* () {
460464 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 );
461485 if (!(yield* CloudCliState.readCliDesiredCloudLink)) return;
462486 const server = yield* HttpServer.HttpServer;
463487 const address = server.address;
infra/relay/src/environments/EnvironmentConnector.test.ts+3
......@@ -188,6 +188,7 @@ function makeAllocations(
188188 tunnelName: "tunnel-name",
189189 dnsRecordId: "dns-record-id",
190190 readyAt: "2026-05-25T00:00:00.000Z",
191 updatedAt: "2026-05-25T00:00:00.000Z",
191192 },
192193): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {
193194 return {
......@@ -196,6 +197,7 @@ function makeAllocations(
196197 recordTunnel: () => Effect.die("unused"),
197198 recordDns: () => Effect.die("unused"),
198199 markReady: () => Effect.die("unused"),
200 claimRelease: () => Effect.die("unused"),
199201 remove: () => Effect.die("unused"),
200202 };
201203}
......@@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => {
467469 tunnelName: "tunnel-name",
468470 dnsRecordId: "dns-record-id",
469471 readyAt: null,
472 updatedAt: "2026-05-25T00:00:00.000Z",
470473 }),
471474 }),
472475 ),
infra/relay/src/environments/EnvironmentLinker.test.ts+1
......@@ -137,6 +137,7 @@ function testLayer(input?: {
137137 }),
138138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
139139 deprovision: input?.deprovision ?? (() => Effect.void),
140 release: () => Effect.succeed(true),
140141 provision: () =>
141142 Effect.succeed({
142143 endpoint: {
infra/relay/src/environments/ManagedEndpointAllocations.ts+54
......@@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation {
1818 readonly tunnelName: string;
1919 readonly dnsRecordId: string | null;
2020 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;
2126}
2227
2328export function resolveReadyManagedEndpoint(input: {
......@@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
4550 "record-tunnel",
4651 "record-dns",
4752 "mark-ready",
53 "claim-release",
4854 "remove",
4955 ]),
5056 stage: Schema.Literals(["database-request", "resolve-reservation"]),
......@@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey {
8086 readonly dnsRecordId: string;
8187}
8288
89interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
90 readonly tunnelId: string;
91 readonly updatedAt: string;
92}
93
8394export class ManagedEndpointAllocations extends Context.Service<
8495 ManagedEndpointAllocations,
8596 {
......@@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service<
98109 readonly markReady: (
99110 input: ManagedEndpointAllocationKey,
100111 ) => 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>;
101122 readonly remove: (
102123 input: ManagedEndpointAllocationKey,
103124 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
......@@ -112,6 +133,7 @@ const allocationSelection = {
112133 tunnelName: relayManagedEndpointAllocations.tunnelName,
113134 dnsRecordId: relayManagedEndpointAllocations.dnsRecordId,
114135 readyAt: relayManagedEndpointAllocations.readyAt,
136 updatedAt: relayManagedEndpointAllocations.updatedAt,
115137};
116138
117139const whereAllocation = (input: ManagedEndpointAllocationKey) =>
......@@ -267,6 +289,38 @@ export const make = Effect.gen(function* () {
267289 ),
268290 );
269291 }),
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 }),
270324 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
271325 input: ManagedEndpointAllocationKey,
272326 ) {
infra/relay/src/environments/ManagedEndpointProvider.test.ts+198-14
......@@ -41,7 +41,14 @@ interface DnsCall {
4141}
4242
4343interface 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";
4552 readonly input: unknown;
4653}
4754
......@@ -148,6 +155,18 @@ function makeDnsClient(
148155
149156function makeAllocations(calls: AllocationCall[] = []) {
150157 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 };
151170 return ManagedEndpointAllocations.ManagedEndpointAllocations.of({
152171 get: (input) =>
153172 Effect.sync(() => {
......@@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
162181 tunnelId: null,
163182 dnsRecordId: null,
164183 readyAt: null,
184 updatedAt: `generation-${++generation}`,
165185 };
166186 allocations.set(allocationKey(input), allocation);
167187 return allocation;
......@@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) {
169189 recordTunnel: (input) =>
170190 Effect.sync(() => {
171191 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 }));
176196 }),
177197 recordDns: (input) =>
178198 Effect.sync(() => {
179199 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 }));
184204 }),
185205 markReady: (input) =>
186206 Effect.sync(() => {
187207 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 });
188216 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;
194223 }
224 mutate(allocationKey(input), (current) => current);
225 return true;
195226 }),
196227 remove: (input) =>
197228 Effect.sync(() => {
......@@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => {
706737 },
707738 );
708739
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
709893 it.effect("treats an absent allocation as already deprovisioned", () => {
710894 const tunnelCalls: TunnelCall[] = [];
711895 const dnsCalls: DnsCall[] = [];
infra/relay/src/environments/ManagedEndpointProvider.ts+83
......@@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M
7676
7777const ManagedEndpointDeprovisioningStage = Schema.Literals([
7878 "load-allocation",
79 "claim-release",
7980 "delete-dns-record",
8081 "delete-tunnel",
8182 "remove-allocation",
......@@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service<
134135 readonly userId: string;
135136 readonly environmentId: string;
136137 }) => 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>;
137154 }
138155>()("t3code-relay/environments/ManagedEndpointProvider") {}
139156
......@@ -467,6 +484,72 @@ export const make = Effect.gen(function* () {
467484 ),
468485 );
469486 }),
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 }),
470553 provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) {
471554 yield* Effect.annotateCurrentSpan({
472555 "relay.user_id": input.userId,
infra/relay/src/http/Api.ts+17
......@@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group(
617617 }
618618 return { ok: unlinked };
619619 }, 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")),
620637 );
621638 }),
622639);
packages/contracts/src/relay.ts+15
......@@ -962,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client")
962962 success: RelayOkResponse,
963963 error: RelayAuthAndInternalErrors,
964964 }).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 ),
965980 )
966981 .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.")
967982 .middleware(RelayClientAuth);