1import {
2 ServerSelfUpdateError,
3 type ServerSelfUpdateCapability,
4 type ServerSelfUpdateInput,
5 type ServerSelfUpdateProgressEvent,
6 type ServerSelfUpdateProgressStage,
7 type ServerSelfUpdateResult,
8} from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Config from "effect/Config";
11import * as Context from "effect/Context";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Ref from "effect/Ref";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Path from "effect/Path";
19import * as Queue from "effect/Queue";
20import * as Stream from "effect/Stream";
21import { HttpClient } from "effect/http";
22
23import {
24 CLI_RELEASE_BASE_URL_ENV,
25 cliReleaseChannelOf,
26 crossesReleaseTrack,
27} from "@t3tools/shared/cliRelease";
28
29import packageJson from "../../package.json" with { type: "json" };
30import * as ServerConfig from "../config.ts";
31import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
32import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts";
33import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
34import { managedUpdateMirror, managedUpdateRefusal } from "../policy/managedUpdates.ts";
35import * as ProcessRunner from "../processRunner.ts";
36import {
37 ensurePinnedRuntimeInstalled,
38 isUnpublishedRelease,
39 pinnedRuntimeCommand,
40 PinnedRuntimeInstallError,
41 pinnedRuntimePaths,
42 PinnedRuntimePreflightBlockedError,
43 prunePinnedRuntimes,
44} from "./pinnedRuntime.ts";
45import { bootServiceProgramOf, bootServiceUnitPath } from "./bootService.ts";
46import { decodeServicePreflightResult } from "./servicePreflight.ts";
47import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
48import { isExactServiceVersion } from "./serviceProtocol.ts";
49
50// A staged runtime's cold start can take over a minute on a loaded, swapping host.
51const PREFLIGHT_TIMEOUT = Duration.minutes(3);
52
53export function resolveServerSelfUpdateCapability(input: {
54 readonly desktopManaged: boolean;
55 readonly launcherManaged: boolean;
56}): ServerSelfUpdateCapability | null {
57 if (input.desktopManaged) return "desktop-managed" as const;
58 return input.launcherManaged ? ("boot-service" as const) : null;
59}
60
61export class ServerSelfUpdate extends Context.Service<
62 ServerSelfUpdate,
63 {
64 readonly update: (
65 input: ServerSelfUpdateInput,
66 reportProgress?: (
67 stage: ServerSelfUpdateProgressStage,
68 ) => Effect.Effect<void, ServerSelfUpdateError>,
69 onHandoffAccepted?: () => Effect.Effect<void>,
70 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
71 readonly commitDesktopUpdate: (
72 requestId: string,
73 onHandoffAccepted?: () => Effect.Effect<void>,
74 ) => Effect.Effect<never, ServerSelfUpdateError>;
75 }
76>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
77
78/**
79 * A server starting from the runtime its launcher settled on means runtimes left by earlier
80 * updates can go. The boot service says so through its IPC context. The SSH launcher and the
81 * install script's `t3` start this home's pinned runtime directly, without one.
82 */
83export const pruneRuntimesOnStart = Effect.fn("cloud.server_self_update.prune_on_start")(
84 function* (input: { readonly baseDir: string; readonly managed: boolean }) {
85 const fs = yield* FileSystem.FileSystem;
86 const path = yield* Path.Path;
87 const platform = yield* HostProcess.Platform;
88 const executablePath = yield* HostProcess.ExecutablePath;
89 const { entryPath } = pinnedRuntimePaths(path, input.baseDir, packageJson.version, platform);
90 // The executable path is resolved, so it names the real path of a symlinked T3 home.
91 const realEntryPath = yield* fs.realPath(entryPath).pipe(Effect.orElseSucceed(() => entryPath));
92 if (!input.managed && executablePath !== entryPath && executablePath !== realEntryPath) {
93 return [];
94 }
95 const unitPath = yield* bootServiceUnitPath();
96 // A stopped service's unit still names the launcher runtime it will start.
97 const unit =
98 unitPath === undefined
99 ? Option.none<string>()
100 : yield* fs.readFileString(unitPath).pipe(Effect.option);
101 const program = Option.flatMapNullishOr(unit, bootServiceProgramOf);
102 // The unit may name it through another spelling of a symlinked T3 home.
103 const references = Option.isNone(program)
104 ? []
105 : [
106 program.value,
107 yield* fs.realPath(program.value).pipe(Effect.orElseSucceed(() => program.value)),
108 ];
109 const removed = yield* prunePinnedRuntimes({
110 baseDir: input.baseDir,
111 keep: [packageJson.version],
112 references,
113 });
114 if (removed.length > 0) {
115 yield* Effect.logInfo("Removed unused t3 runtimes.", { versions: removed });
116 }
117 return removed;
118 },
119);
120
121/** The client checks this when planning; the host checks again because its database may have
122 * moved on since (another client updated it). Stable and Nightly each build their own commits,
123 * so crossing tracks needs the target release to know every migration this database holds. */
124export function trackSwitchBlockReason(input: {
125 readonly serverVersion: string;
126 readonly targetVersion: string;
127 readonly targetDatabaseMigration: number | undefined;
128 /** Read only for a crossing; undefined when the database cannot be read. */
129 readonly databaseMigration: () => number | undefined;
130}): string | null {
131 if (!crossesReleaseTrack(input.serverVersion, input.targetVersion)) return null;
132 const databaseMigration = input.databaseMigration();
133 if (input.targetDatabaseMigration === undefined || databaseMigration === undefined) {
134 return `Cannot confirm this database works with t3@${input.targetVersion}. Switch tracks from Settings → About → Update track, which checks it first.`;
135 }
136 return input.targetDatabaseMigration < databaseMigration
137 ? `This database has changes t3@${input.targetVersion} does not support yet; it stays on this release.`
138 : null;
139}
140
141export const make = Effect.fn("cloud.server_self_update.make")(function* () {
142 const serverConfig = yield* ServerConfig.ServerConfig;
143 const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
144 const policy = yield* ManagedPolicy.ManagedPolicy;
145 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
146 const runner = yield* ProcessRunner.ProcessRunner;
147 const fs = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;
149 const platform = yield* HostProcess.Platform;
150 const arch = yield* HostProcess.Architecture;
151 const httpClient = yield* HttpClient.HttpClient;
152 const mirror = managedUpdateMirror(policy.document);
153 const releaseBaseUrl =
154 mirror?.feedUrl ??
155 Option.getOrUndefined(yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option));
156 const inFlight = yield* Ref.make(false);
157
158 yield* pruneRuntimesOnStart({ baseDir: serverConfig.baseDir, managed: launcher.managed }).pipe(
159 Effect.forkScoped,
160 );
161
162 const capability: ServerSelfUpdateCapability | null =
163 serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
164 const failWith = (reason: string, cause?: unknown) =>
165 cause === undefined
166 ? new ServerSelfUpdateError({ reason })
167 : new ServerSelfUpdateError({ reason, cause });
168
169 const crossesTrackUnsafely = (input: ServerSelfUpdateInput) =>
170 trackSwitchBlockReason({
171 serverVersion: packageJson.version,
172 targetVersion: input.targetVersion,
173 targetDatabaseMigration: input.targetDatabaseMigration,
174 databaseMigration: () => databaseMigrationHead(serverConfig.dbPath),
175 });
176
177 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
178 "cloud.server_self_update.update",
179 )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
180 const policyRefusal = managedUpdateRefusal(
181 policy.document,
182 capability === "boot-service" ? cliReleaseChannelOf(input.targetVersion.trim()) : undefined,
183 );
184 if (policyRefusal !== undefined) {
185 return yield* failWith(policyRefusal);
186 }
187 if (capability === "desktop-managed") {
188 const unsafeCrossing = input.channel === undefined ? null : crossesTrackUnsafely(input);
189 if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing);
190 if (desktopAppUpdate.available) {
191 return yield* desktopAppUpdate.run(
192 reportProgress,
193 input.channel === undefined
194 ? undefined
195 : { channel: input.channel, targetVersion: input.targetVersion },
196 );
197 }
198 return yield* failWith(
199 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
200 );
201 }
202 // The target must accept the launcher that will run it, which can be older than this server.
203 const launcherProtocol = launcher.protocol;
204 if (capability === null || launcherProtocol === undefined) {
205 return yield* failWith(
206 "Remote updates require the T3 Code background service. Run `t3 service install` on the server machine.",
207 );
208 }
209
210 const unsafeCrossing = crossesTrackUnsafely(input);
211 if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing);
212 const targetVersion = input.targetVersion.trim();
213 if (!isExactServiceVersion(targetVersion)) {
214 return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);
215 }
216 if (yield* Ref.getAndSet(inFlight, true)) {
217 return yield* failWith("A server update is already in progress.");
218 }
219
220 return yield* Effect.gen(function* () {
221 yield* reportProgress("downloading");
222 const paths = yield* ensurePinnedRuntimeInstalled({
223 baseDir: serverConfig.baseDir,
224 version: targetVersion,
225 fs,
226 path,
227 runner,
228 httpClient,
229 platform,
230 arch,
231 releaseBaseUrl,
232 requirePublishedRelease: mirror !== undefined,
233 validate: (runtime) =>
234 runner
235 .run({
236 command: pinnedRuntimeCommand(runtime).command,
237 args: [
238 ...pinnedRuntimeCommand(runtime).args,
239 "__service-preflight",
240 "--database-path",
241 serverConfig.dbPath,
242 "--launcher-protocol",
243 String(launcherProtocol),
244 ],
245 timeout: PREFLIGHT_TIMEOUT,
246 })
247 .pipe(
248 Effect.mapError(
249 (cause) =>
250 new PinnedRuntimeInstallError({
251 step: "running the staged service preflight",
252 cause,
253 }),
254 ),
255 Effect.flatMap(
256 (
257 result,
258 ): Effect.Effect<
259 void,
260 PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
261 > => {
262 if (result.code !== 0) {
263 return Effect.fail(
264 new PinnedRuntimeInstallError({
265 step: "running the staged service preflight",
266 exitCode: Number(result.code),
267 stdoutLength: result.stdout.length,
268 stderrLength: result.stderr.length,
269 }),
270 );
271 }
272 let parsed: unknown;
273 try {
274 parsed = JSON.parse(result.stdout.trim());
275 } catch (cause) {
276 return Effect.fail(
277 new PinnedRuntimeInstallError({
278 step: "decoding the staged service preflight",
279 cause,
280 }),
281 );
282 }
283 const preflight = decodeServicePreflightResult(parsed, launcherProtocol);
284 if (preflight === undefined || preflight.version !== targetVersion) {
285 return Effect.fail(
286 new PinnedRuntimeInstallError({
287 step: "verifying the staged service preflight",
288 }),
289 );
290 }
291 return preflight.status === "ready"
292 ? Effect.void
293 : Effect.fail(
294 new PinnedRuntimePreflightBlockedError({
295 version: targetVersion,
296 reason: preflight.reason,
297 }),
298 );
299 },
300 ),
301 ),
302 }).pipe(
303 Effect.mapError((error) => {
304 if (error._tag === "PinnedRuntimePreflightBlockedError") {
305 return failWith(error.reason, error);
306 }
307 if (mirror !== undefined && isUnpublishedRelease(error)) {
308 return failWith(
309 `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`,
310 error,
311 );
312 }
313 const downloadFailure = [
314 error.httpStatus === undefined ? undefined : `HTTP ${error.httpStatus}`,
315 error.downloadHost === undefined ? undefined : `from ${error.downloadHost}`,
316 ]
317 .filter((part) => part !== undefined)
318 .join(" ");
319 return failWith(
320 `Could not prepare t3@${targetVersion} while ${error.step}${downloadFailure ? ` (${downloadFailure})` : ""}.`,
321 error,
322 );
323 }),
324 );
325
326 yield* reportProgress("installing");
327 const updateId = yield* Effect.uninterruptible(
328 launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe(
329 Effect.mapError((error) =>
330 failWith(
331 error._tag === "ServiceLauncherRejectedError"
332 ? error.reason
333 : "Could not ask the service launcher to activate the prepared update.",
334 error,
335 ),
336 ),
337 Effect.tap(() => onHandoffAccepted()),
338 ),
339 );
340
341 yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", {
342 updateId,
343 targetVersion,
344 runtimePath: paths.entryPath,
345 });
346 return { targetVersion, method: "boot-service" as const, updateId };
347 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
348 });
349
350 return ServerSelfUpdate.of({
351 update,
352 commitDesktopUpdate: Effect.fn("ServerSelfUpdate.commitDesktopUpdate")(
353 function (requestId, onHandoffAccepted) {
354 return desktopAppUpdate.commit(requestId, onHandoffAccepted);
355 },
356 ),
357 });
358});
359
360export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
361 Layer.provide(ProcessRunner.layer),
362);
363
364// The update outlives client sockets; progress offers to a closed queue are ignored.
365export const updateProgressStream = (
366 selfUpdate: ServerSelfUpdate["Service"],
367 input: ServerSelfUpdateInput,
368) =>
369 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) => {
370 const offer = (event: ServerSelfUpdateProgressEvent) =>
371 Queue.offer(queue, event).pipe(Effect.ignore);
372
373 return selfUpdate
374 .update(input, (stage) => offer({ type: "progress", stage }))
375 .pipe(
376 Effect.flatMap((result) => offer({ type: "complete", result })),
377 Effect.catchTags({
378 ServerSelfUpdateError: (error) => Queue.fail(queue, error).pipe(Effect.ignore),
379 }),
380 Effect.andThen(Queue.end(queue).pipe(Effect.ignore)),
381 Effect.forkDetach,
382 );
383 });