Skip to content
255 changes: 254 additions & 1 deletion apps/server/src/cloud/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<readonly [string, string]> = []) {
const values = new Map<string, Uint8Array>(
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<unknown>;
readonly requests: Array<HttpClientRequest.HttpClientRequest>;
readonly respond?: () => Response;
}

const provideReleaseHarness =
(harness: ReleaseHarness) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];
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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

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"],
Expand Down
73 changes: 72 additions & 1 deletion apps/server/src/cloud/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RelayEnvironmentLinkProofPayload,
RelayLinkProofRequest,
RelayManagedEndpointOrigin,
RelayOkResponse,
} from "@t3tools/contracts/relay";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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`,
Comment thread
juliusmarminge marked this conversation as resolved.
).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;
Comment thread
juliusmarminge marked this conversation as resolved.
});

const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
dependencies: CloudHttpDependencies,
) {
Expand Down
Loading
Loading