| 1 | import * as Duration from "effect/Duration"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Hex from "effect/encoding/Hex"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import * as Semaphore from "effect/Semaphore"; |
| 10 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 11 | |
| 12 | import { |
| 13 | CLI_RELEASE_CHECKSUMS_FILE, |
| 14 | cliArchiveFileName, |
| 15 | cliArchivePlatformKey, |
| 16 | cliArchiveTarCommand, |
| 17 | cliReleaseDownloadBaseUrl, |
| 18 | parseChecksums, |
| 19 | } from "@t3tools/shared/cliRelease"; |
| 20 | |
| 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 22 | |
| 23 | /** |
| 24 | * A pinned runtime is an exact t3 release archive unpacked into |
| 25 | * <baseDir>/runtime/versions/<version>: the self-contained executable, the |
| 26 | * web client, and the native packages beside it. The boot service points its |
| 27 | * unit or launch agent at the executable, and server self-update installs the |
| 28 | * target version here before switching over. The runtime never depends on a |
| 29 | * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` |
| 30 | * package for people who prefer `npx t3` or `npm install -g t3`, and even a |
| 31 | * CLI installed that way pins an archive when it sets up the service. |
| 32 | */ |
| 33 | const PINNED_RUNTIME_DIR = "runtime"; |
| 34 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); |
| 35 | const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; |
| 36 | // Boot-service setup and remote update can construct separate layers. Serialize |
| 37 | // the complete install transaction across every caller in this process. |
| 38 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); |
| 39 | |
| 40 | export interface PinnedRuntimePaths { |
| 41 | readonly versionDir: string; |
| 42 | /** The executable. Its existence is what marks a runtime as present. */ |
| 43 | readonly entryPath: string; |
| 44 | readonly sentinelPath: string; |
| 45 | } |
| 46 | |
| 47 | /** The exact command that runs a pinned runtime. */ |
| 48 | export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { |
| 49 | readonly command: string; |
| 50 | readonly args: ReadonlyArray<string>; |
| 51 | } { |
| 52 | return { command: paths.entryPath, args: [] }; |
| 53 | } |
| 54 | |
| 55 | export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string { |
| 56 | return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); |
| 57 | } |
| 58 | |
| 59 | export function pinnedRuntimePaths( |
| 60 | path: Path.Path, |
| 61 | baseDir: string, |
| 62 | version: string, |
| 63 | platform: NodeJS.Platform, |
| 64 | ): PinnedRuntimePaths { |
| 65 | const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version); |
| 66 | return { |
| 67 | versionDir, |
| 68 | entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), |
| 69 | sentinelPath: path.join(versionDir, ".install-complete"), |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | export class PinnedRuntimeInstallError extends Schema.TaggedError<PinnedRuntimeInstallError>()( |
| 74 | "PinnedRuntimeInstallError", |
| 75 | { |
| 76 | step: Schema.String, |
| 77 | exitCode: Schema.optional(Schema.Number), |
| 78 | stdoutLength: Schema.optional(Schema.Number), |
| 79 | stderrLength: Schema.optional(Schema.Number), |
| 80 | cause: Schema.optional(Schema.Defect()), |
| 81 | }, |
| 82 | ) { |
| 83 | override get message(): string { |
| 84 | return this.exitCode === undefined |
| 85 | ? `Pinned runtime install failed while ${this.step}.` |
| 86 | : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<PinnedRuntimePreflightBlockedError>()( |
| 91 | "PinnedRuntimePreflightBlockedError", |
| 92 | { |
| 93 | version: Schema.String, |
| 94 | reason: Schema.String, |
| 95 | }, |
| 96 | ) { |
| 97 | override get message(): string { |
| 98 | return this.reason; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | export type PinnedRuntimeProgress = |
| 103 | | { readonly stage: "download"; readonly received: number; readonly total: number | undefined } |
| 104 | | { readonly stage: "verify" | "extract" | "validate" | "cached" }; |
| 105 | |
| 106 | /** |
| 107 | * Installs the t3 release archive for `version` into the pinned runtime |
| 108 | * directory unless a complete install is already there, and returns its |
| 109 | * paths. The sentinel is written only after extraction and validation |
| 110 | * succeed; checking the entry file alone is not enough, since tar writes the |
| 111 | * executable before the last native package and a killed install leaves a |
| 112 | * plausible-looking but broken tree behind. |
| 113 | */ |
| 114 | |
| 115 | interface PinnedRuntimeInstallInput { |
| 116 | readonly baseDir: string; |
| 117 | readonly version: string; |
| 118 | readonly fs: FileSystem.FileSystem; |
| 119 | readonly path: Path.Path; |
| 120 | readonly runner: ProcessRunner.ProcessRunner["Service"]; |
| 121 | readonly validate: ( |
| 122 | paths: PinnedRuntimePaths, |
| 123 | ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>; |
| 124 | readonly platform: NodeJS.Platform; |
| 125 | readonly arch: string; |
| 126 | readonly httpClient: HttpClient.HttpClient; |
| 127 | readonly releaseBaseUrl?: string | undefined; |
| 128 | readonly onProgress?: (progress: PinnedRuntimeProgress) => void; |
| 129 | } |
| 130 | |
| 131 | const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( |
| 132 | httpClient: HttpClient.HttpClient, |
| 133 | url: string, |
| 134 | step: string, |
| 135 | onProgress?: (progress: PinnedRuntimeProgress) => void, |
| 136 | ) { |
| 137 | // The install lock is held for the whole transaction, so a stalled download |
| 138 | // must fail rather than block every other caller. |
| 139 | return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( |
| 140 | Effect.flatMap(HttpClientResponse.filterStatusOk), |
| 141 | Effect.flatMap( |
| 142 | Effect.fn(function* (response) { |
| 143 | if (onProgress === undefined) return new Uint8Array(yield* response.arrayBuffer); |
| 144 | const length = Number(response.headers["content-length"]); |
| 145 | const total = Number.isFinite(length) && length > 0 ? length : undefined; |
| 146 | let received = 0; |
| 147 | onProgress({ stage: "download", received, total }); |
| 148 | const chunks = yield* response.stream.pipe( |
| 149 | Stream.tap((chunk) => |
| 150 | Effect.sync(() => { |
| 151 | received += chunk.byteLength; |
| 152 | onProgress({ stage: "download", received, total }); |
| 153 | }), |
| 154 | ), |
| 155 | Stream.runCollect, |
| 156 | ); |
| 157 | // A completed chunked response finally gives us its total size. |
| 158 | if (total === undefined && received > 0) { |
| 159 | onProgress({ stage: "download", received, total: received }); |
| 160 | } |
| 161 | const bytes = new Uint8Array(received); |
| 162 | let offset = 0; |
| 163 | for (const chunk of chunks) { |
| 164 | bytes.set(chunk, offset); |
| 165 | offset += chunk.byteLength; |
| 166 | } |
| 167 | return bytes; |
| 168 | }), |
| 169 | ), |
| 170 | Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), |
| 171 | Effect.timeoutOrElse({ |
| 172 | duration: PINNED_RUNTIME_INSTALL_TIMEOUT, |
| 173 | orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })), |
| 174 | }), |
| 175 | ); |
| 176 | }); |
| 177 | |
| 178 | /** |
| 179 | * Downloads the release archive for this platform, verifies it against the |
| 180 | * release's checksum file, and unpacks it so the executable sits directly in |
| 181 | * the staging directory. Only `tar` is required on the host; every supported |
| 182 | * OS ships one that reads gzip and zip. |
| 183 | */ |
| 184 | const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* ( |
| 185 | input: PinnedRuntimeInstallInput, |
| 186 | stagingDir: string, |
| 187 | ) { |
| 188 | const { fs, path } = input; |
| 189 | const platformKey = cliArchivePlatformKey(input.platform, input.arch); |
| 190 | if (platformKey === undefined) { |
| 191 | return yield* new PinnedRuntimeInstallError({ |
| 192 | step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, |
| 193 | }); |
| 194 | } |
| 195 | const httpClient = input.httpClient; |
| 196 | const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); |
| 197 | const fileName = cliArchiveFileName(input.version, platformKey); |
| 198 | |
| 199 | input.onProgress?.({ stage: "download", received: 0, total: undefined }); |
| 200 | const checksums = parseChecksums( |
| 201 | new TextDecoder().decode( |
| 202 | yield* fetchReleaseAsset( |
| 203 | httpClient, |
| 204 | `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`, |
| 205 | "downloading the t3 release checksums", |
| 206 | ), |
| 207 | ), |
| 208 | ); |
| 209 | const expected = checksums.get(fileName); |
| 210 | if (expected === undefined) { |
| 211 | return yield* new PinnedRuntimeInstallError({ |
| 212 | step: `finding ${fileName} in the t3 release checksums`, |
| 213 | }); |
| 214 | } |
| 215 | const archive = yield* fetchReleaseAsset( |
| 216 | httpClient, |
| 217 | `${baseUrl}/${fileName}`, |
| 218 | "downloading the t3 release archive", |
| 219 | input.onProgress, |
| 220 | ); |
| 221 | input.onProgress?.({ stage: "verify" }); |
| 222 | const digest = yield* Effect.tryPromise({ |
| 223 | try: () => crypto.subtle.digest("SHA-256", archive), |
| 224 | catch: (cause) => |
| 225 | new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }), |
| 226 | }); |
| 227 | if (Hex.encode(new Uint8Array(digest)) !== expected) { |
| 228 | return yield* new PinnedRuntimeInstallError({ |
| 229 | step: "verifying the t3 release archive checksum", |
| 230 | }); |
| 231 | } |
| 232 | |
| 233 | const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE); |
| 234 | yield* fs |
| 235 | .writeFile(archivePath, archive) |
| 236 | .pipe( |
| 237 | Effect.mapError( |
| 238 | (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), |
| 239 | ), |
| 240 | ); |
| 241 | input.onProgress?.({ stage: "extract" }); |
| 242 | const extractStep = "extracting the t3 release archive"; |
| 243 | // The archive wraps everything in one directory named after its stem; |
| 244 | // strip it so the executable lands at <versionDir>/t3. |
| 245 | yield* input.runner |
| 246 | .run({ |
| 247 | command: cliArchiveTarCommand(input.platform, process.env), |
| 248 | args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"], |
| 249 | timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, |
| 250 | }) |
| 251 | .pipe( |
| 252 | Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })), |
| 253 | Effect.filterOrFail( |
| 254 | (result) => result.code === 0, |
| 255 | (result) => |
| 256 | new PinnedRuntimeInstallError({ |
| 257 | step: extractStep, |
| 258 | exitCode: Number(result.code), |
| 259 | stdoutLength: result.stdout.length, |
| 260 | stderrLength: result.stderr.length, |
| 261 | }), |
| 262 | ), |
| 263 | ); |
| 264 | yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); |
| 265 | }); |
| 266 | |
| 267 | const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( |
| 268 | input: PinnedRuntimeInstallInput, |
| 269 | ) { |
| 270 | const { fs } = input; |
| 271 | const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); |
| 272 | const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ |
| 273 | fs.exists(paths.versionDir), |
| 274 | fs.exists(paths.entryPath), |
| 275 | fs.readFileString(paths.sentinelPath).pipe(Effect.option), |
| 276 | ]).pipe( |
| 277 | Effect.mapError( |
| 278 | (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), |
| 279 | ), |
| 280 | ); |
| 281 | const alreadyPinned = |
| 282 | entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; |
| 283 | if (alreadyPinned) { |
| 284 | input.onProgress?.({ stage: "cached" }); |
| 285 | yield* input.validate(paths); |
| 286 | return paths; |
| 287 | } |
| 288 | if (versionDirExists) { |
| 289 | yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( |
| 290 | Effect.mapError( |
| 291 | (cause) => |
| 292 | new PinnedRuntimeInstallError({ |
| 293 | step: "removing an incomplete pinned runtime", |
| 294 | cause, |
| 295 | }), |
| 296 | ), |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | const versionsDir = input.path.dirname(paths.versionDir); |
| 301 | yield* fs.makeDirectory(versionsDir, { recursive: true }).pipe( |
| 302 | Effect.mapError( |
| 303 | (cause) => |
| 304 | new PinnedRuntimeInstallError({ |
| 305 | step: "preparing the pinned runtime directory", |
| 306 | cause, |
| 307 | }), |
| 308 | ), |
| 309 | ); |
| 310 | const stagingDir = yield* fs |
| 311 | .makeTempDirectory({ |
| 312 | directory: versionsDir, |
| 313 | prefix: ".staging-", |
| 314 | }) |
| 315 | .pipe( |
| 316 | Effect.mapError( |
| 317 | (cause) => |
| 318 | new PinnedRuntimeInstallError({ |
| 319 | step: "preparing the pinned runtime directory", |
| 320 | cause, |
| 321 | }), |
| 322 | ), |
| 323 | ); |
| 324 | const stagingPaths: PinnedRuntimePaths = { |
| 325 | versionDir: stagingDir, |
| 326 | entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), |
| 327 | sentinelPath: input.path.join(stagingDir, ".install-complete"), |
| 328 | }; |
| 329 | |
| 330 | return yield* Effect.gen(function* () { |
| 331 | yield* installFromArchive(input, stagingDir); |
| 332 | |
| 333 | input.onProgress?.({ stage: "validate" }); |
| 334 | yield* input.validate(stagingPaths); |
| 335 | yield* fs |
| 336 | .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) |
| 337 | .pipe( |
| 338 | Effect.mapError( |
| 339 | (cause) => |
| 340 | new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), |
| 341 | ), |
| 342 | ); |
| 343 | const published = yield* fs.rename(stagingDir, paths.versionDir).pipe( |
| 344 | Effect.as(true), |
| 345 | Effect.catch((cause) => |
| 346 | Effect.all([ |
| 347 | fs.exists(paths.entryPath), |
| 348 | fs.readFileString(paths.sentinelPath).pipe(Effect.option), |
| 349 | ]).pipe( |
| 350 | Effect.mapError( |
| 351 | (checkCause) => |
| 352 | new PinnedRuntimeInstallError({ |
| 353 | step: "checking a concurrently published pinned runtime", |
| 354 | cause: checkCause, |
| 355 | }), |
| 356 | ), |
| 357 | Effect.flatMap(([publishedEntryExists, publishedSentinel]) => |
| 358 | publishedEntryExists && |
| 359 | Option.isSome(publishedSentinel) && |
| 360 | publishedSentinel.value.trim() === input.version |
| 361 | ? Effect.succeed(false) |
| 362 | : Effect.fail( |
| 363 | new PinnedRuntimeInstallError({ |
| 364 | step: "publishing the pinned runtime", |
| 365 | cause, |
| 366 | }), |
| 367 | ), |
| 368 | ), |
| 369 | ), |
| 370 | ), |
| 371 | ); |
| 372 | if (!published) yield* input.validate(paths); |
| 373 | return paths; |
| 374 | }).pipe( |
| 375 | Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), |
| 376 | ); |
| 377 | }); |
| 378 | |
| 379 | export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) => |
| 380 | pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input)); |