| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Path from "effect/Path"; |
| 5 | 7 | import * as PlatformError from "effect/PlatformError"; |
| 6 | 8 | import * as Tracer from "effect/Tracer"; |
| 7 | 9 | import { |
| ... | ... | @@ -15,6 +17,7 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 15 | 17 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 16 | 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 17 | 19 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 20 | import * as ServerConfigModule from "../config.ts"; |
| 18 | 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | 22 | import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; |
| 20 | 23 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| ... | ... | @@ -256,6 +259,21 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 256 | 259 | readonly respond?: () => Response; |
| 257 | 260 | } |
| 258 | 261 | |
| 262 | // Writes the launcher's durable state file into this test's baseDir; the |
| 263 | // release reads it to detect an in-flight update handoff. |
| 264 | const writeServiceState = (update: Record<string, unknown>) => |
| 265 | Effect.gen(function* () { |
| 266 | const fs = yield* FileSystem.FileSystem; |
| 267 | const path = yield* Path.Path; |
| 268 | const config = yield* ServerConfigModule.ServerConfig; |
| 269 | const statePath = path.join(config.baseDir, "runtime", "service-state.json"); |
| 270 | yield* fs.makeDirectory(path.dirname(statePath), { recursive: true }); |
| 271 | yield* fs.writeFileString( |
| 272 | statePath, |
| 273 | JSON.stringify({ version: 1, activeVersion: "0.0.30", update }), |
| 274 | ); |
| 275 | }); |
| 276 | |
| 259 | 277 | const provideReleaseHarness = |
| 260 | 278 | (harness: ReleaseHarness) => |
| 261 | 279 | <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| ... | ... | @@ -306,6 +324,11 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 306 | 324 | }), |
| 307 | 325 | ), |
| 308 | 326 | ), |
| 327 | // The release consults the launcher state file under the configured |
| 328 | // baseDir, so every harness run gets a scoped temp baseDir. |
| 329 | Effect.provide(ServerConfigModule.layerTest("/", { prefix: "t3-http-release-test-" })), |
| 330 | Effect.provide(NodeServices.layer), |
| 331 | Effect.scoped, |
| 309 | 332 | ); |
| 310 | 333 | |
| 311 | 334 | // The persisted state of a CLI-managed link whose tunnel is releasable. |
| ... | ... | @@ -390,6 +413,54 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 390 | 413 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 391 | 414 | }); |
| 392 | 415 | |
| 416 | it.effect("keeps the tunnel when shutdown hands off to a pending update", () => { |
| 417 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| 418 | const applyConfigCalls: Array<unknown> = []; |
| 419 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| 420 | |
| 421 | return Effect.gen(function* () { |
| 422 | yield* writeServiceState({ |
| 423 | id: "update-1", |
| 424 | fromVersion: "0.0.30", |
| 425 | targetVersion: "0.0.31", |
| 426 | dbPath: "/tmp/state.sqlite", |
| 427 | status: "pending", |
| 428 | }); |
| 429 | |
| 430 | const released = yield* releaseManagedTunnelOnShutdown(); |
| 431 | |
| 432 | // The launcher restarts a server immediately, so the tunnel is not |
| 433 | // orphaned; keeping it avoids the hostname route re-propagation that |
| 434 | // dominates update downtime. The stored config must survive so the |
| 435 | // next boot respawns the connector against the same tunnel. |
| 436 | expect(released).toBe(false); |
| 437 | expect(applyConfigCalls).toEqual([]); |
| 438 | expect(requests).toEqual([]); |
| 439 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); |
| 440 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 441 | }); |
| 442 | |
| 443 | it.effect("still releases when the recorded update already settled", () => { |
| 444 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| 445 | const applyConfigCalls: Array<unknown> = []; |
| 446 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| 447 | |
| 448 | return Effect.gen(function* () { |
| 449 | yield* writeServiceState({ |
| 450 | id: "update-1", |
| 451 | fromVersion: "0.0.30", |
| 452 | targetVersion: "0.0.31", |
| 453 | status: "committed", |
| 454 | }); |
| 455 | |
| 456 | const released = yield* releaseManagedTunnelOnShutdown(); |
| 457 | |
| 458 | expect(released).toBe(true); |
| 459 | expect(requests).toHaveLength(1); |
| 460 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); |
| 461 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 462 | }); |
| 463 | |
| 393 | 464 | it.effect("keeps a runtime config that a fast restart replaced mid-release", () => { |
| 394 | 465 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| 395 | 466 | const applyConfigCalls: Array<unknown> = []; |