diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 96abf2e4b4a..c779c13aaf4 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -4,19 +4,28 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Tracer from "effect/Tracer"; -import { HttpClient, HttpServerRequest } from "effect/unstable/http"; +import { + HttpClient, + HttpClientResponse, + HttpServerRequest, + type HttpClientRequest, +} from "effect/unstable/http"; +import { EnvironmentId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts"; import { consumeCloudReplayGuards, isSupportedLinkProviderKind, linkProofScopes, reconcileDesiredCloudLink, + releaseManagedTunnelOnShutdown, } from "./http.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; @@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => { ); }); +describe("releaseManagedTunnelOnShutdown", () => { + const cliToken: CliTokenManager.PersistedToken = { + accessToken: "cli-access-token", + refreshToken: "cli-refresh-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }; + + function makeMemorySecretStore(initial: Iterable = []) { + const values = new Map( + Array.from(initial, ([name, value]) => [name, new TextEncoder().encode(value)] as const), + ); + const store: ServerSecretStore.ServerSecretStore["Service"] = { + get: (name) => Effect.sync(() => Option.fromNullishOr(values.get(name))), + set: (name, value) => + Effect.sync(() => { + values.set(name, value); + }), + create: unusedSecretStoreOperation, + getOrCreateRandom: unusedSecretStoreOperation, + remove: (name) => + Effect.sync(() => { + values.delete(name); + }), + }; + return { store, values }; + } + + interface ReleaseHarness { + readonly store: ServerSecretStore.ServerSecretStore["Service"]; + readonly applyConfigCalls: Array; + readonly requests: Array; + readonly respond?: () => Response; + } + + const provideReleaseHarness = + (harness: ReleaseHarness) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), + Effect.provideService( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")), + getDescriptor: Effect.die("unused"), + }), + ), + Effect.provideService( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: (config) => + Effect.sync(() => { + harness.applyConfigCalls.push(config); + return { + status: "disabled", + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus; + }), + }), + ), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), + ), + Effect.provideService( + CliTokenManager.CloudCliTokenManager, + CliTokenManager.CloudCliTokenManager.of({ + get: unusedSecretStoreOperation(), + getExisting: Effect.succeed(Option.some(cliToken)), + hasCredential: unusedSecretStoreOperation(), + store: () => unusedSecretStoreOperation(), + clear: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + harness.requests.push(request); + return HttpClientResponse.fromWeb( + request, + (harness.respond ?? (() => Response.json({ ok: true })))(), + ); + }), + ), + ), + ); + + // The persisted state of a CLI-managed link whose tunnel is releasable. + const managedLinkSecrets = [ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], + ] as const; + + it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(true); + expect(applyConfigCalls).toEqual([null]); + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.method).toBe("DELETE"); + expect(request.url).toBe( + "https://relay.example.test/v1/client/environment-links/env_123/tunnel", + ); + expect(request.headers.authorization).toBe("Bearer cli-access-token"); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("does nothing for links without a stored managed tunnel runtime config", () => { + const { store } = makeMemorySecretStore(); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("leaves the tunnel of a web/mobile-installed link untouched", () => { + // A managed runtime config without a CLI-desired link: the environment was + // linked by a web/mobile client, and nothing re-provisions the tunnel on + // the next boot, so shutdown must not release it. + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("leaves the tunnel of a publish-only desired link untouched", () => { + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_CLI_DESIRED_LINK_SECRET, "publish_only"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("keeps a runtime config that a fast restart replaced mid-release", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + const freshConfig = new TextEncoder().encode("fresh-runtime-config"); + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(true); + // The finalizer only drops the config it released; the one written by + // the restarted process while the DELETE was in flight stays. + expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => { + // A restarted process reconciled and stored a fresh connector config + // while this shutdown's release request was in flight. + values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig); + return Response.json({ ok: true }); + }, + }), + ); + }); + + it.effect("keeps the stored connector token when the relay skipped the release", () => { + // ok:false means a concurrent provision owns the recorded tunnel, so the + // stored runtime config (possibly freshly written by that provision) must + // survive. + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const released = yield* releaseManagedTunnelOnShutdown(); + + expect(released).toBe(false); + expect(requests).toHaveLength(1); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({ ok: false }), + }), + ); + }); + + it.effect("keeps the stored connector token when the relay release request fails", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const result = yield* Effect.result(releaseManagedTunnelOnShutdown()); + + expect(result._tag).toBe("Failure"); + expect(requests).toHaveLength(1); + // The tunnel still exists, so the stored token stays valid across the + // restart and the next boot can bring the connector back immediately. + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({ ok: false }, { status: 503 }), + }), + ); + }); +}); + describe("link proof provider kinds", () => { const proofRequest = ( providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 92423bb215e..eaf31a37f27 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -28,6 +28,7 @@ import { RelayEnvironmentLinkProofPayload, RelayLinkProofRequest, RelayManagedEndpointOrigin, + RelayOkResponse, } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { @@ -68,7 +69,11 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts"; +import { + readCliDesiredCloudLink, + readCliDesiredLinkMode, + setCliDesiredCloudLink, +} from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; @@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD }, ); +// Cloudflare bills per provisioned tunnel, so an environment that goes offline +// must not leave its tunnel behind. Releasing deletes only the tunnel — the +// relay keeps the link and its hostname reservation, and the next startup's +// link reconcile provisions a replacement tunnel under the same URL. +export const releaseManagedTunnelOnShutdown = Effect.fn( + "environment.cloud.releaseManagedTunnelOnShutdown", +)(function* () { + const dependencies = yield* cloudHttpDependencies; + // Only a managed link stores a runtime config; publish-only links have no + // tunnel to release. + const runtimeConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if (Option.isNone(runtimeConfig)) { + return false; + } + // Only CLI-desired managed links release on shutdown, because the startup + // reconcile that provisions the replacement tunnel only runs for them. A + // link installed by a web/mobile client comes back after a restart by + // reapplying the stored connector token — it has no boot-time re-provision + // path — so its tunnel must survive the restart. (Unlink still deletes it.) + if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") { + return false; + } + const token = yield* dependencies.cliTokenManager.getExisting; + if (Option.isNone(token)) { + return false; + } + // The link belongs to the relay it was installed against, so target the + // persisted URL: T3CODE_RELAY_URL may have changed since the link was made. + const relayUrl = yield* dependencies.secrets.get(RELAY_URL_SECRET); + if (Option.isNone(relayUrl)) { + return false; + } + const environmentId = yield* dependencies.environment.getEnvironmentId; + // Stop the local connector before the relay deletes the tunnel it serves. + yield* dependencies.endpointRuntime.applyConfig(null); + const response = yield* HttpClientRequest.delete( + `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`, + ).pipe( + HttpClientRequest.bearerToken(token.value.accessToken), + dependencies.httpClient.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + withRelayClientTracing, + ); + // ok:false means the relay skipped deletion because a concurrent provision + // owns the recorded tunnel now — leave the stored config alone. + if (!response.ok) { + return false; + } + // The connector token died with the tunnel. Drop the stored config so the + // next start waits for the link reconcile instead of respawning the relay + // client with a dead token. Kept when the release request fails: the tunnel + // still exists, so the stored token keeps working across the restart. + // Only dropped while it is still the config this shutdown released — a fast + // restart may already have reconciled and stored a fresh config for its + // replacement tunnel, and that one must survive this finalizer. + const storedConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isSome(storedConfig) && + bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) + ) { + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + return true; +}); + const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( dependencies: CloudHttpDependencies, ) { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..0412d910b8c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import { + connectHttpApiLayer, + reconcileDesiredCloudLink, + releaseManagedTunnelOnShutdown, +} from "./cloud/http.ts"; import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; @@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap( const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) return; + // Idle Cloudflare tunnels are billed, so a stopping server releases its + // tunnel; the persisted desired link brings one back — same hostname, + // fresh tunnel — when the environment starts again. Registered even + // when no link is desired yet: a client can link a running server, and + // that tunnel needs the same disposal on shutdown. + yield* Effect.addFinalizer(() => + releaseManagedTunnelOnShutdown().pipe( + Effect.timeout("10 seconds"), + Effect.tap((released) => + released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning( + "Failed to release the managed tunnel on shutdown; the next link reuses it", + { cause }, + ), + ), + Effect.asVoid, + ), + ); if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; const server = yield* HttpServer.HttpServer; const address = server.address; diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 63f12379870..6e3388e3977 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -188,6 +188,7 @@ function makeAllocations( tunnelName: "tunnel-name", dnsRecordId: "dns-record-id", readyAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", }, ): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { return { @@ -196,6 +197,7 @@ function makeAllocations( recordTunnel: () => Effect.die("unused"), recordDns: () => Effect.die("unused"), markReady: () => Effect.die("unused"), + claimRelease: () => Effect.die("unused"), remove: () => Effect.die("unused"), }; } @@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => { tunnelName: "tunnel-name", dnsRecordId: "dns-record-id", readyAt: null, + updatedAt: "2026-05-25T00:00:00.000Z", }), }), ), diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 18a362f5247..7488939f0f9 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -137,6 +137,7 @@ function testLayer(input?: { }), Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { deprovision: input?.deprovision ?? (() => Effect.void), + release: () => Effect.succeed(true), provision: () => Effect.succeed({ endpoint: { diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index f6cefa69071..f0349de99e1 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation { readonly tunnelName: string; readonly dnsRecordId: string | null; readonly readyAt: string | null; + /** + * Doubles as the allocation's generation marker: every mutation rewrites it, + * so `claimRelease` can detect a provision that raced a release. + */ + readonly updatedAt: string; } export function resolveReadyManagedEndpoint(input: { @@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro "record-tunnel", "record-dns", "mark-ready", + "claim-release", "remove", ]), stage: Schema.Literals(["database-request", "resolve-reservation"]), @@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { readonly dnsRecordId: string; } +interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey { + readonly tunnelId: string; + readonly updatedAt: string; +} + export class ManagedEndpointAllocations extends Context.Service< ManagedEndpointAllocations, { @@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service< readonly markReady: ( input: ManagedEndpointAllocationKey, ) => Effect.Effect; + /** + * Atomically claims the right to delete the allocation's tunnel: succeeds + * only while the recorded tunnel and generation still match what the + * caller loaded. A concurrent provision rewrites `updatedAt` when it + * records its tunnel, which makes a stale claim fail and keeps the freshly + * issued tunnel alive. + */ + readonly claimRelease: ( + input: ClaimManagedEndpointReleaseInput, + ) => Effect.Effect; readonly remove: ( input: ManagedEndpointAllocationKey, ) => Effect.Effect; @@ -112,6 +133,7 @@ const allocationSelection = { tunnelName: relayManagedEndpointAllocations.tunnelName, dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, readyAt: relayManagedEndpointAllocations.readyAt, + updatedAt: relayManagedEndpointAllocations.updatedAt, }; const whereAllocation = (input: ManagedEndpointAllocationKey) => @@ -267,6 +289,38 @@ export const make = Effect.gen(function* () { ), ); }), + claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* ( + input: ClaimManagedEndpointReleaseInput, + ) { + const claimed = yield* db + .update(relayManagedEndpointAllocations) + .set({ + updatedAt: DateTime.formatIso(yield* DateTime.now), + }) + .where( + and( + whereAllocation(input), + eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId), + eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), + ), + ) + .returning({ userId: relayManagedEndpointAllocations.userId }) + .pipe( + Effect.map((rows) => rows.length > 0), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "claim-release", + stage: "database-request", + userId: input.userId, + environmentId: input.environmentId, + tunnelId: input.tunnelId, + cause, + }), + ), + ); + return claimed; + }), remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( input: ManagedEndpointAllocationKey, ) { diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index b0ba22099c5..d075dc29076 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -41,7 +41,14 @@ interface DnsCall { } interface AllocationCall { - readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove"; + readonly operation: + | "get" + | "reserve" + | "recordTunnel" + | "recordDns" + | "markReady" + | "claimRelease" + | "remove"; readonly input: unknown; } @@ -148,6 +155,18 @@ function makeDnsClient( function makeAllocations(calls: AllocationCall[] = []) { const allocations = new Map(); + let generation = 0; + const mutate = ( + key: string, + change: ( + allocation: ManagedEndpointAllocations.ManagedEndpointAllocation, + ) => ManagedEndpointAllocations.ManagedEndpointAllocation, + ) => { + const allocation = allocations.get(key); + if (allocation !== undefined) { + allocations.set(key, { ...change(allocation), updatedAt: `generation-${++generation}` }); + } + }; return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ get: (input) => Effect.sync(() => { @@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) { tunnelId: null, dnsRecordId: null, readyAt: null, + updatedAt: `generation-${++generation}`, }; allocations.set(allocationKey(input), allocation); return allocation; @@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) { recordTunnel: (input) => Effect.sync(() => { calls.push({ operation: "recordTunnel", input }); - const allocation = allocations.get(allocationKey(input)); - if (allocation !== undefined) { - allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); - } + mutate(allocationKey(input), (allocation) => ({ + ...allocation, + tunnelId: input.tunnelId, + })); }), recordDns: (input) => Effect.sync(() => { calls.push({ operation: "recordDns", input }); - const allocation = allocations.get(allocationKey(input)); - if (allocation !== undefined) { - allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId }); - } + mutate(allocationKey(input), (allocation) => ({ + ...allocation, + dnsRecordId: input.dnsRecordId, + })); }), markReady: (input) => Effect.sync(() => { calls.push({ operation: "markReady", input }); + mutate(allocationKey(input), (allocation) => ({ + ...allocation, + readyAt: "2026-06-02T00:00:00.000Z", + })); + }), + claimRelease: (input) => + Effect.sync(() => { + calls.push({ operation: "claimRelease", input }); const allocation = allocations.get(allocationKey(input)); - if (allocation !== undefined) { - allocations.set(allocationKey(input), { - ...allocation, - readyAt: "2026-06-02T00:00:00.000Z", - }); + if ( + allocation === undefined || + allocation.tunnelId !== input.tunnelId || + allocation.updatedAt !== input.updatedAt + ) { + return false; } + mutate(allocationKey(input), (current) => current); + return true; }), remove: (input) => Effect.sync(() => { @@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => { }, ); + it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; + const first = yield* provider.provision({ ...key, origin }); + const released = yield* provider.release(key); + const second = yield* provider.provision({ ...key, origin }); + + expect(released).toBe(true); + expect(second.endpoint).toEqual(first.endpoint); + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + // first provision + "list", + "create", + "putConfiguration", + "getToken", + // release deletes only the tunnel... + "delete", + // ...and the next provision recreates it under the same name + "list", + "create", + "putConfiguration", + "getToken", + ]); + // The DNS record survives the release and is repointed, never deleted. + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "updateRecord", + ]); + expect(allocationCalls.map((call) => call.operation)).not.toContain("remove"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("treats an environment without a recorded tunnel as already released", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const released = yield* provider.release({ userId: "user_ABC", environmentId: "env_ABC" }); + + expect(released).toBe(true); + expect(tunnelCalls).toEqual([]); + expect(dnsCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the tunnel alive when a concurrent provision outdates the release claim", () => { + const tunnelCalls: TunnelCall[] = []; + const allocations = makeAllocations(); + // Simulates a provision racing the release: the allocation generation no + // longer matches what the release loaded, so the claim fails. + const outdated = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ + ...allocations, + claimRelease: () => Effect.succeed(false), + }); + const layer = providerLayer(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), outdated); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + const released = yield* provider.release(key); + + // false tells the caller its connector token is still live, so it must + // keep its runtime config. + expect(released).toBe(false); + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + "list", + "create", + "putConfiguration", + "getToken", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("treats an already deleted tunnel as successfully released", () => { + const notFound = { _tag: "NotFound" } as const; + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + delete: (tunnelId) => + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause: notFound, + }), + ), + }); + const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + yield* provider.release(key); + }).pipe(Effect.provide(layer)); + }); + + it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => { + const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", + cause: "Cloudflare tunnel deletion failed", + }); + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + delete: () => Effect.fail(failure), + }); + const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + const error = yield* Effect.flip(provider.release(key)); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointDeprovisioningFailed", + stage: "delete-tunnel", + userId: key.userId, + environmentId: key.environmentId, + tunnelId: "tunnel-id", + }); + expect(error.cause).toBe(failure); + }).pipe(Effect.provide(layer)); + }); + it.effect("treats an absent allocation as already deprovisioned", () => { const tunnelCalls: TunnelCall[] = []; const dnsCalls: DnsCall[] = []; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 28e7c750eb3..d2ebe37053a 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass Effect.Effect; + /** + * Deletes the provisioned Cloudflare tunnel while keeping the allocation + * (hostname + tunnel name reservation) and DNS record. Cloudflare bills per + * provisioned tunnel, so environments release the tunnel when they shut + * down; the next `provision` recreates it under the same name and repoints + * the CNAME, preserving the endpoint URL. + * + * Resolves to whether the caller's connector token is now dead: true when + * the tunnel was deleted (or none was recorded to begin with), false when + * a concurrent provision outbid the release claim and the recorded tunnel + * — and any token issued for it — stays live. + */ + readonly release: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; } >()("t3code-relay/environments/ManagedEndpointProvider") {} @@ -467,6 +484,72 @@ export const make = Effect.gen(function* () { ), ); }), + release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.user_id": input.userId, + "relay.environment_id": input.environmentId, + }); + const allocation = yield* allocations.get(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "load-allocation", + cause, + }), + ), + ); + const tunnelId = allocation?.tunnelId ?? null; + if (allocation === null || tunnelId === null) { + return true; + } + // Claim the release against the allocation's current generation before + // touching Cloudflare. A provision racing this release (fast environment + // restart) rewrites updatedAt when it records its tunnel, so a stale + // claim means the recorded tunnel may already back a fresh connector and + // must be left alive. A provision that starts after the claim instead + // fails loudly on the deleted tunnel and the client-side retry + // provisions a replacement. + const claimed = yield* allocations + .claimRelease({ + userId: input.userId, + environmentId: input.environmentId, + tunnelId, + updatedAt: allocation.updatedAt, + }) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "claim-release", + tunnelId, + cause, + }), + ), + ); + if (!claimed) { + return false; + } + yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-tunnel", + tunnelId, + cause, + }), + ), + ); + // The recorded tunnelId is now stale, but the allocation row is left + // untouched deliberately: connect/status authorization requires a fully + // recorded allocation, and an offline environment must keep reporting + // "offline" (health probe fails) rather than "not authorized". The next + // provision lists tunnels by name, finds none, creates a replacement and + // re-records the fresh id. + return true; + }), provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.user_id": input.userId, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index a147414febb..0d5813ab71c 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group( } return { ok: unlinked }; }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "releaseEnvironmentTunnel", + Effect.fn("relay.api.client.releaseEnvironmentTunnel")(function* (args) { + const { params } = args; + const { userId } = yield* RelayClientPrincipal; + // ok mirrors whether the connector token is now dead: false means a + // concurrent provision kept the recorded tunnel alive, so the caller + // must not discard its runtime config. + const released = yield* managedEndpointProvider + .release({ + userId, + environmentId: params.environmentId, + }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); + return { ok: released }; + }, mapRelayCommonApiErrors("not_authorized")), ); }), ); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index b4e044c4ae2..ff9a9e3ac61 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -962,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client") success: RelayOkResponse, error: RelayAuthAndInternalErrors, }).annotate(OpenApi.Summary, "Unlink an environment"), + HttpApiEndpoint.delete( + "releaseEnvironmentTunnel", + "/v1/client/environment-links/:environmentId/tunnel", + { + headers: RelayBearerRequestHeaders, + params: RelayEnvironmentUnlinkParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, + ) + .annotate(OpenApi.Summary, "Release an environment's managed tunnel") + .annotate( + OpenApi.Description, + "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.", + ), ) .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") .middleware(RelayClientAuth);