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