History

fix(server): validate update rollback boundaries

Theo Browne committed 3 months ago

Commit cd25a3d1 parent c07288814 files · +499 −48Browse files

Unified · Split

14 files
  1. apps/server/src/cli/service.ts +2 −2
  2. apps/server/src/cloud/selfUpdate.test.ts +1 −0
  3. apps/server/src/cloud/selfUpdate.ts +1 −0
  4. apps/server/src/cloud/systemdSelfUpdate.acceptance.test.ts +237 −0
  5. apps/server/src/cloud/systemdSelfUpdate.test.ts +56 −4
  6. apps/server/src/cloud/systemdSelfUpdate.ts +8 −7
  7. apps/server/src/http.ts +18 −7
  8. apps/server/src/persistence/Migrations.test.ts +51 −1
  9. apps/server/src/persistence/Migrations.ts +58 −18
  10. apps/server/src/server.test.ts +25 −0
  11. apps/server/src/serverRuntimeStartup.ts +5 −0
  12. docs/internals/server-updates.md +9 −4
  13. docs/operations/release.md +17 −0
  14. packages/contracts/src/environmentHttp.ts +11 −5
apps/server/src/cli/service.ts+2−2
@@ -9,7 +9,7 @@ import packageJson from "../../package.json" with { type: "json" }; · 8 unmodified lines
99 import * as BootService from "../cloud/bootService.ts";
1010 import { applySystemdSelfUpdatePlan } from "../cloud/systemdSelfUpdate.ts";
1111 import type * as ServerConfig from "../config.ts";
12−import { validateMigrationIdentities } from "../persistence/Migrations.ts";
12+import { validateAutomaticUpdateMigrationFrontier } from "../persistence/Migrations.ts";
1313 import * as ProcessRunner from "../processRunner.ts";
1414 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
1515
@@ -169,7 +169,7 @@ const serviceValidateUpdateCommand = Command.make("_validate-update", { · 153 unmodified lines
169169 const NodeSqliteClient = yield* Effect.promise(
170170 () => import("../persistence/NodeSqliteClient.ts"),
171171 );
172− yield* validateMigrationIdentities().pipe(
172+ yield* validateAutomaticUpdateMigrationFrontier().pipe(
173173 Effect.scoped,
174174 Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })),
175175 );
apps/server/src/cloud/selfUpdate.test.ts+1−0
@@ -535,6 +535,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 534 unmodified lines
535535 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SystemdSelfUpdatePlan))),
536536 );
537537 assert.equal(plan.previousUnit, previousUnit);
538+ assert.equal(plan.serviceUnit, BOOT_SERVICE_UNIT_FILE);
538539 assert.include(plan.nextUnit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);
539540 assert.equal(
540541 plan.runtimeStatePath,
apps/server/src/cloud/selfUpdate.ts+1−0
@@ -394,6 +394,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option · 393 unmodified lines
394394 fromVersion: packageJson.version,
395395 targetVersion,
396396 currentPid: process.pid,
397+ serviceUnit: BOOT_SERVICE_UNIT_FILE,
397398 unitPath,
398399 previousUnit,
399400 nextUnit,
apps/server/src/cloud/systemdSelfUpdate.acceptance.test.ts+237−0
@@ -0,0 +1,237 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+/**
3+ * Opt-in acceptance coverage for the real systemd handoff and rollback path.
4+ * Every unit and file is uniquely named; this must never target t3code.service.
5+ *
6+ * Run on Linux with a user manager:
7+ * T3_SYSTEMD_SELF_UPDATE_ACCEPTANCE=1 vp test run \
8+ * apps/server/src/cloud/systemdSelfUpdate.acceptance.test.ts
9+ */
10+import * as NodeServices from "@effect/platform-node/NodeServices";
11+import { assert, it } from "@effect/vitest";
12+import * as Clock from "effect/Clock";
13+import * as Duration from "effect/Duration";
14+import * as Effect from "effect/Effect";
15+import * as FileSystem from "effect/FileSystem";
16+import * as Layer from "effect/Layer";
17+import * as Option from "effect/Option";
18+import * as Path from "effect/Path";
19+import * as Schema from "effect/Schema";
20+import { FetchHttpClient } from "effect/unstable/http";
21+import { describe } from "vite-plus/test";
22+
23+import * as ProcessRunner from "../processRunner.ts";
24+import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
25+import { quoteSystemdValue } from "./bootService.ts";
26+import {
27+ SystemdSelfUpdatePlan,
28+ SystemdSelfUpdateReceipt,
29+ waitForSystemdSelfUpdateReadiness,
30+ writeSystemdSelfUpdatePlan,
31+} from "./systemdSelfUpdate.ts";
32+
33+const OLD_VERSION = "0.0.28";
34+const TARGET_VERSION = "0.0.29";
35+const decodeLaunch = Schema.decodeUnknownSync(
36+ Schema.fromJsonString(
37+ Schema.Struct({
38+ pid: Schema.Int,
39+ version: Schema.String,
40+ }),
41+ ),
42+);
43+
44+const fixtureServerSource = `
45+import * as fs from "node:fs";
46+import * as http from "node:http";
47+
48+const [runtimeStatePath, launchesPath, version, readiness] = process.argv.slice(2);
49+const descriptor = {
50+ environmentId: "systemd-self-update-acceptance",
51+ label: "Systemd self-update acceptance",
52+ platform: { os: "linux", arch: "x64" },
53+ serverVersion: version,
54+ capabilities: { repositoryIdentity: true },
55+};
56+const server = http.createServer((request, response) => {
57+ if (request.url === "/.well-known/t3/ready" && readiness === "ready") {
58+ response.writeHead(200, { "content-type": "application/json" });
59+ response.end(JSON.stringify(descriptor));
60+ return;
61+ }
62+ response.writeHead(503);
63+ response.end();
64+});
65+server.listen(0, "127.0.0.1", () => {
66+ const address = server.address();
67+ if (!address || typeof address === "string") process.exit(1);
68+ fs.appendFileSync(launchesPath, JSON.stringify({ pid: process.pid, version }) + "\\n");
69+ fs.writeFileSync(runtimeStatePath, JSON.stringify({
70+ version: 1,
71+ pid: process.pid,
72+ port: address.port,
73+ origin: "http://127.0.0.1:" + address.port,
74+ startedAt: new Date().toISOString(),
75+ }) + "\\n");
76+});
77+`;
78+
79+const runAcceptanceScenario = Effect.fn("test.runSystemdSelfUpdateAcceptance")(function* (
80+ scenario: "healthy" | "rollback",
81+) {
82+ const fs = yield* FileSystem.FileSystem;
83+ const path = yield* Path.Path;
84+ const runner = yield* ProcessRunner.ProcessRunner;
85+ const home = process.env.HOME;
86+ if (!home) {
87+ return yield* Effect.die(new Error("HOME is required for a systemd user-manager test."));
88+ }
89+
90+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: `t3-systemd-${scenario}-` });
91+ const runtimeStatePath = path.join(directory, "server-runtime.json");
92+ const launchesPath = path.join(directory, "launches.jsonl");
93+ const receiptPath = path.join(directory, "receipt.json");
94+ const planPath = path.join(directory, "plan.json");
95+ const fixtureServerPath = path.join(directory, "fixture-server.mjs");
96+ yield* fs.writeFileString(fixtureServerPath, fixtureServerSource);
97+
98+ const suffix = `${String(process.pid)}-${String(yield* Clock.currentTimeMillis)}-${scenario}`;
99+ const serviceUnit = `t3code-self-update-acceptance-${suffix}.service`;
100+ const helperUnit = `t3code-self-update-acceptance-helper-${suffix}.service`;
101+ const userUnitDirectory = path.join(home, ".config", "systemd", "user");
102+ const unitPath = path.join(userUnitDirectory, serviceUnit);
103+ yield* fs.makeDirectory(userUnitDirectory, { recursive: true });
104+
105+ const renderUnit = (version: string, readiness: "ready" | "blocked") =>
106+ [
107+ "[Unit]",
108+ "Description=T3 self-update acceptance fixture",
109+ "",
110+ "[Service]",
111+ "Type=simple",
112+ `ExecStart=${quoteSystemdValue(process.execPath)} ${quoteSystemdValue(fixtureServerPath)} ${quoteSystemdValue(runtimeStatePath)} ${quoteSystemdValue(launchesPath)} ${version} ${readiness}`,
113+ "Restart=no",
114+ "TimeoutStopSec=5",
115+ "",
116+ ].join("\n");
117+ const previousUnit = renderUnit(OLD_VERSION, "ready");
118+ const nextUnit = renderUnit(TARGET_VERSION, scenario === "healthy" ? "ready" : "blocked");
119+
120+ const systemctl = (args: ReadonlyArray<string>) =>
121+ runner.run({ command: "systemctl", args: ["--user", ...args] });
122+ const cleanup = Effect.gen(function* () {
123+ yield* systemctl(["stop", helperUnit, serviceUnit]).pipe(Effect.ignore);
124+ yield* systemctl(["reset-failed", helperUnit, serviceUnit]).pipe(Effect.ignore);
125+ yield* fs.remove(unitPath, { force: true }).pipe(Effect.ignore);
126+ yield* systemctl(["daemon-reload"]).pipe(Effect.ignore);
127+ });
128+ yield* Effect.addFinalizer(() => cleanup);
129+
130+ yield* fs.writeFileString(unitPath, previousUnit);
131+ const reload = yield* systemctl(["daemon-reload"]);
132+ assert.equal(Number(reload.code), 0);
133+ const start = yield* systemctl(["start", serviceUnit]);
134+ assert.equal(Number(start.code), 0);
135+ yield* waitForSystemdSelfUpdateReadiness(
136+ {
137+ expectedVersion: OLD_VERSION,
138+ previousPid: 0,
139+ runtimeStatePath,
140+ },
141+ { timeout: Duration.seconds(10), interval: Duration.millis(100) },
142+ );
143+ const initialState = yield* readPersistedServerRuntimeState(runtimeStatePath);
144+ if (Option.isNone(initialState)) {
145+ return yield* Effect.die(
146+ new Error("The disposable systemd service did not publish runtime state."),
147+ );
148+ }
149+
150+ const plan = new SystemdSelfUpdatePlan({
151+ version: 1,
152+ fromVersion: OLD_VERSION,
153+ targetVersion: TARGET_VERSION,
154+ currentPid: initialState.value.pid,
155+ serviceUnit,
156+ unitPath,
157+ previousUnit,
158+ nextUnit,
159+ runtimeStatePath,
160+ receiptPath,
161+ });
162+ yield* writeSystemdSelfUpdatePlan(planPath, plan);
163+
164+ const repositoryRoot = path.resolve(import.meta.dirname, "../../../..");
165+ const cliEntryPath = path.join(repositoryRoot, "apps", "server", "src", "bin.ts");
166+ const handoff = yield* runner.run({
167+ command: "systemd-run",
168+ args: [
169+ "--user",
170+ "--wait",
171+ "--collect",
172+ "--service-type=exec",
173+ `--unit=${helperUnit}`,
174+ process.execPath,
175+ "--experimental-strip-types",
176+ cliEntryPath,
177+ "service",
178+ "_apply-update",
179+ "--plan",
180+ planPath,
181+ ],
182+ timeout: Duration.seconds(90),
183+ });
184+ assert.equal(Number(handoff.code), 0, handoff.stderr);
185+
186+ const receipt = yield* fs
187+ .readFileString(receiptPath)
188+ .pipe(
189+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SystemdSelfUpdateReceipt))),
190+ );
191+ assert.equal(receipt.phase, scenario === "healthy" ? "healthy" : "rolled-back");
192+ assert.equal(
193+ yield* fs.readFileString(unitPath),
194+ scenario === "healthy" ? nextUnit : previousUnit,
195+ );
196+
197+ yield* waitForSystemdSelfUpdateReadiness(
198+ {
199+ expectedVersion: scenario === "healthy" ? TARGET_VERSION : OLD_VERSION,
200+ previousPid: initialState.value.pid,
201+ runtimeStatePath,
202+ },
203+ { timeout: Duration.seconds(10), interval: Duration.millis(100) },
204+ );
205+ const launches = (yield* fs.readFileString(launchesPath))
206+ .trim()
207+ .split("\n")
208+ .map((line) => decodeLaunch(line));
209+ assert.deepEqual(
210+ launches.map(({ version }) => version),
211+ scenario === "healthy"
212+ ? [OLD_VERSION, TARGET_VERSION]
213+ : [OLD_VERSION, TARGET_VERSION, OLD_VERSION],
214+ );
215+});
216+
217+describe.runIf(process.env.T3_SYSTEMD_SELF_UPDATE_ACCEPTANCE === "1")(
218+ "systemd self-update acceptance",
219+ () => {
220+ const layer = ProcessRunner.layer.pipe(
221+ Layer.provideMerge(NodeServices.layer),
222+ Layer.merge(FetchHttpClient.layer),
223+ );
224+
225+ it.live(
226+ "keeps the helper alive while replacing the host service",
227+ () => runAcceptanceScenario("healthy").pipe(Effect.provide(layer)),
228+ 30_000,
229+ );
230+
231+ it.live(
232+ "rolls back a server that binds HTTP without becoming runtime-ready",
233+ () => runAcceptanceScenario("rollback").pipe(Effect.provide(layer)),
234+ 120_000,
235+ );
236+ },
237+);
apps/server/src/cloud/systemdSelfUpdate.test.ts+56−4
@@ -6,14 +6,17 @@ import * as FileSystem from "effect/FileSystem"; · 5 unmodified lines
66 import * as Layer from "effect/Layer";
77 import * as Path from "effect/Path";
88 import * as Schema from "effect/Schema";
9+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
910 import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1011
1112 import * as ProcessRunner from "../processRunner.ts";
13+import { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
1214 import {
1315 SystemdSelfUpdateActivationError,
1416 SystemdSelfUpdatePlan,
1517 SystemdSelfUpdateReceipt,
1618 activateSystemdSelfUpdatePlan,
19+ waitForSystemdSelfUpdateReadiness,
1720 } from "./systemdSelfUpdate.ts";
1821
1922 interface RecordedCommand {
@@ -59,6 +62,7 @@ it.layer(NodeServices.layer)("systemd self-update activation", (it) => { · 39 unmodified lines
5962 fromVersion: "0.0.28",
6063 targetVersion: "0.0.29",
6164 currentPid: 123,
65+ serviceUnit: "t3code-self-update-test.service",
6266 unitPath,
6367 previousUnit,
6468 nextUnit: "new unit\n",
@@ -95,7 +99,7 @@ it.layer(NodeServices.layer)("systemd self-update activation", (it) => { · 30 unmodified lines
9599 commands.map(({ args }) => args),
96100 [
97101 ["--user", "daemon-reload"],
98− ["--user", "restart", "t3code.service"],
102+ ["--user", "restart", "t3code-self-update-test.service"],
99103 ],
100104 );
101105 assert.equal((yield* readReceipt(fixture.receiptPath)).phase, "healthy");
@@ -132,10 +136,10 @@ it.layer(NodeServices.layer)("systemd self-update activation", (it) => { · 30 unmodified lines
132136 commands.map(({ args }) => args),
133137 [
134138 ["--user", "daemon-reload"],
135− ["--user", "restart", "t3code.service"],
139+ ["--user", "restart", "t3code-self-update-test.service"],
136140 ["--user", "daemon-reload"],
137− ["--user", "reset-failed", "t3code.service"],
138− ["--user", "restart", "t3code.service"],
141+ ["--user", "reset-failed", "t3code-self-update-test.service"],
142+ ["--user", "restart", "t3code-self-update-test.service"],
139143 ],
140144 );
141145 const receipt = yield* readReceipt(fixture.receiptPath);
@@ -164,4 +168,52 @@ it.layer(NodeServices.layer)("systemd self-update activation", (it) => { · 22 unmodified lines
164168 assert.equal((yield* readReceipt(fixture.receiptPath)).phase, "recovery-failed");
165169 }),
166170 );
171+
172+ it.effect("probes the runtime-readiness endpoint on the replacement process", () =>
173+ Effect.gen(function* () {
174+ const fs = yield* FileSystem.FileSystem;
175+ const path = yield* Path.Path;
176+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-systemd-ready-test-" });
177+ const runtimeStatePath = path.join(directory, "server-runtime.json");
178+ const encodeRuntimeState = Schema.encodeEffect(
179+ Schema.fromJsonString(PersistedServerRuntimeState),
180+ );
181+ const encodedRuntimeState = yield* encodeRuntimeState({
182+ version: 1,
183+ pid: 456,
184+ port: 3210,
185+ origin: "http://127.0.0.1:3210",
186+ startedAt: "2026-01-01T00:00:00.000Z",
187+ });
188+ yield* fs.writeFileString(runtimeStatePath, `${encodedRuntimeState}\n`);
189+
190+ const requests: Array<string> = [];
191+ const httpClientLayer = Layer.succeed(
192+ HttpClient.HttpClient,
193+ HttpClient.make((request) => {
194+ requests.push(request.url);
195+ return Effect.succeed(
196+ HttpClientResponse.fromWeb(
197+ request,
198+ Response.json({
199+ environmentId: "environment-test",
200+ label: "Test environment",
201+ platform: { os: "linux", arch: "x64" },
202+ serverVersion: "0.0.29",
203+ capabilities: { repositoryIdentity: true },
204+ }),
205+ ),
206+ );
207+ }),
208+ );
209+
210+ yield* waitForSystemdSelfUpdateReadiness({
211+ expectedVersion: "0.0.29",
212+ previousPid: 123,
213+ runtimeStatePath,
214+ }).pipe(Effect.provide(httpClientLayer));
215+
216+ assert.deepEqual(requests, ["http://127.0.0.1:3210/.well-known/t3/ready"]);
217+ }),
218+ );
167219 });
apps/server/src/cloud/systemdSelfUpdate.ts+8−7
@@ -12,7 +12,6 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 11 unmodified lines
1212 import { writeFileStringAtomically } from "../atomicWrite.ts";
1313 import * as ProcessRunner from "../processRunner.ts";
1414 import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
15−import { BOOT_SERVICE_UNIT_FILE } from "./bootService.ts";
1615
1716 export const SYSTEMD_SELF_UPDATE_UNIT = "t3code-self-update.service";
1817 export const SYSTEMD_SELF_UPDATE_DIRECTORY = "self-update";
@@ -23,7 +22,7 @@ const DEFAULT_RESTART_DELAY = Duration.seconds(2); · 4 unmodified lines
2322 const DEFAULT_READINESS_TIMEOUT = Duration.seconds(60);
2423 const DEFAULT_READINESS_INTERVAL = Duration.millis(500);
2524 const READINESS_REQUEST_TIMEOUT = Duration.seconds(2);
26−const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
25+const WELL_KNOWN_READY_PATH = "/.well-known/t3/ready";
2726
2827 export class SystemdSelfUpdatePlan extends Schema.Class<SystemdSelfUpdatePlan>(
2928 "SystemdSelfUpdatePlan",
@@ -32,6 +31,7 @@ export class SystemdSelfUpdatePlan extends Schema.Class<SystemdSelfUpdatePlan>( · 2 unmodified lines
3231 fromVersion: Schema.String,
3332 targetVersion: Schema.String,
3433 currentPid: Schema.Int,
34+ serviceUnit: Schema.String,
3535 unitPath: Schema.String,
3636 previousUnit: Schema.String,
3737 nextUnit: Schema.String,
@@ -185,7 +185,8 @@ const installUnit = Effect.fn("systemdSelfUpdate.installUnit")(function* ( · 147 unmodified lines
185185 yield* runSystemctl(["--user", "daemon-reload"], "daemon-reload");
186186 });
187187
188−const restartBootService = runSystemctl(["--user", "restart", BOOT_SERVICE_UNIT_FILE], "restart");
188+const restartBootService = (serviceUnit: string) =>
189+ runSystemctl(["--user", "restart", serviceUnit], "restart");
189190
190191 export interface SystemdSelfUpdateReadinessInput {
191192 readonly expectedVersion: string;
@@ -227,7 +228,7 @@ export const waitForSystemdSelfUpdateReadiness = Effect.fn("systemdSelfUpdate.wa · 35 unmodified lines
227228 }
228229
229230 const request = HttpClientRequest.get(
230− new URL(WELL_KNOWN_ENVIRONMENT_PATH, state.value.origin).toString(),
231+ new URL(WELL_KNOWN_READY_PATH, state.value.origin).toString(),
231232 );
232233 const descriptor = yield* client.execute(request).pipe(
233234 Effect.timeout(READINESS_REQUEST_TIMEOUT),
@@ -277,7 +278,7 @@ export const activateSystemdSelfUpdatePlan = Effect.fn("systemdSelfUpdate.activa · 43 unmodified lines
277278 yield* writeReceipt(plan, "activating").pipe(Effect.ignore);
278279 yield* Effect.sleep(options.restartDelay ?? DEFAULT_RESTART_DELAY);
279280 yield* installUnit(plan, plan.nextUnit);
280− yield* restartBootService;
281+ yield* restartBootService(plan.serviceUnit);
281282 yield* waitForReadiness({
282283 expectedVersion: plan.targetVersion,
283284 previousPid: plan.currentPid,
@@ -298,8 +299,8 @@ export const activateSystemdSelfUpdatePlan = Effect.fn("systemdSelfUpdate.activa · 14 unmodified lines
298299 );
299300 const recovery = Effect.gen(function* () {
300301 yield* installUnit(plan, plan.previousUnit);
301− yield* runSystemctl(["--user", "reset-failed", BOOT_SERVICE_UNIT_FILE], "reset-failed");
302− yield* restartBootService;
302+ yield* runSystemctl(["--user", "reset-failed", plan.serviceUnit], "reset-failed");
303+ yield* restartBootService(plan.serviceUnit);
303304 yield* waitForReadiness({
304305 expectedVersion: plan.fromVersion,
305306 previousPid: plan.currentPid,
apps/server/src/http.ts+18−7
@@ -40,6 +40,7 @@ import { · 39 unmodified lines
4040 } from "./auth/http.ts";
4141 import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
4242 import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
43+import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
4344
4445 const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4546 const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
@@ -177,13 +178,23 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( · 131 unmodified lines
177178 "metadata",
178179 Effect.fnUntraced(function* (handlers) {
179180 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
180− return handlers.handle(
181− "descriptor",
182− Effect.fn("environment.metadata.descriptor")(function* (args) {
183− yield* annotateEnvironmentRequest(args.endpoint.name);
184− return yield* serverEnvironment.getDescriptor;
185− }, traceRelayRequest),
186− );
181+ const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
182+ return handlers
183+ .handle(
184+ "descriptor",
185+ Effect.fn("environment.metadata.descriptor")(function* (args) {
186+ yield* annotateEnvironmentRequest(args.endpoint.name);
187+ return yield* serverEnvironment.getDescriptor;
188+ }, traceRelayRequest),
189+ )
190+ .handle(
191+ "ready",
192+ Effect.fn("environment.metadata.ready")(function* (args) {
193+ yield* annotateEnvironmentRequest(args.endpoint.name);
194+ yield* startup.awaitReady.pipe(Effect.orDie);
195+ return yield* serverEnvironment.getDescriptor;
196+ }, traceRelayRequest),
197+ );
187198 }),
188199 );
189200
apps/server/src/persistence/Migrations.test.ts+51−1
@@ -3,7 +3,12 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
33 import * as Layer from "effect/Layer";
44 import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6−import { MigrationIdentityMismatchError, validateMigrationIdentities } from "./Migrations.ts";
6+import {
7+ AutomaticUpdateMigrationFrontierError,
8+ MigrationIdentityMismatchError,
9+ validateAutomaticUpdateMigrationFrontier,
10+ validateMigrationIdentities,
11+} from "./Migrations.ts";
712 import * as NodeSqliteClient from "./NodeSqliteClient.ts";
813
914 const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
@@ -50,4 +55,49 @@ layer("migration identity validation", (it) => { · 40 unmodified lines
5055 yield* validateMigrationIdentities();
5156 }),
5257 );
58+
59+ it.effect("rejects automatic downgrade when the database has a newer migration", () =>
60+ Effect.gen(function* () {
61+ const sql = yield* SqlClient.SqlClient;
62+ yield* createTrackingTable();
63+ yield* sql`
64+ INSERT INTO effect_sql_migrations (migration_id, name)
65+ VALUES (999, 'FutureMigration')
66+ `;
67+
68+ const error = yield* validateAutomaticUpdateMigrationFrontier().pipe(Effect.flip);
69+ assert.instanceOf(error, AutomaticUpdateMigrationFrontierError);
70+ assert.equal(error.databaseMigrationId, 999);
71+ assert.equal(error.targetMigrationId, 35);
72+ }),
73+ );
74+
75+ it.effect("rejects automatic updates that would run migrations", () =>
76+ Effect.gen(function* () {
77+ const sql = yield* SqlClient.SqlClient;
78+ yield* createTrackingTable();
79+ yield* sql`
80+ INSERT INTO effect_sql_migrations (migration_id, name)
81+ VALUES (34, 'ProjectionThreadsSnoozed')
82+ `;
83+
84+ const error = yield* validateAutomaticUpdateMigrationFrontier().pipe(Effect.flip);
85+ assert.instanceOf(error, AutomaticUpdateMigrationFrontierError);
86+ assert.equal(error.databaseMigrationId, 34);
87+ assert.equal(error.targetMigrationId, 35);
88+ }),
89+ );
90+
91+ it.effect("allows automatic updates on the same migration frontier", () =>
92+ Effect.gen(function* () {
93+ const sql = yield* SqlClient.SqlClient;
94+ yield* createTrackingTable();
95+ yield* sql`
96+ INSERT INTO effect_sql_migrations (migration_id, name)
97+ VALUES (35, 'ProjectionThreadTitleRegeneration')
98+ `;
99+
100+ yield* validateAutomaticUpdateMigrationFrontier();
101+ }),
102+ );
53103 });
apps/server/src/persistence/Migrations.ts+58−18
@@ -127,33 +127,53 @@ export class MigrationIdentityMismatchError extends Schema.TaggedErrorClass<Migr · 126 unmodified lines
127127 }
128128 }
129129
130+export class AutomaticUpdateMigrationFrontierError extends Schema.TaggedErrorClass<AutomaticUpdateMigrationFrontierError>()(
131+ "AutomaticUpdateMigrationFrontierError",
132+ {
133+ databaseMigrationId: Schema.Int,
134+ targetMigrationId: Schema.Int,
135+ },
136+) {
137+ override get message(): string {
138+ return this.databaseMigrationId > this.targetMigrationId
139+ ? `This database is newer than the requested t3 build (migration ${String(this.databaseMigrationId)} vs ${String(this.targetMigrationId)}).`
140+ : `The requested t3 build would migrate this database (migration ${String(this.databaseMigrationId)} to ${String(this.targetMigrationId)}), which is not safe during an automatic update.`;
141+ }
142+}
143+
144+const readPersistedMigrationIdentities = Effect.fn("readPersistedMigrationIdentities")(
145+ function* () {
146+ const sql = yield* SqlClient.SqlClient;
147+ const tables = yield* sql<{ readonly name: string }>`
148+ SELECT name
149+ FROM sqlite_master
150+ WHERE type = 'table' AND name = 'effect_sql_migrations'
151+ `;
152+ if (tables.length === 0) {
153+ return [];
154+ }
155+
156+ return yield* sql<{
157+ readonly migration_id: number;
158+ readonly name: string;
159+ }>`
160+ SELECT migration_id, name
161+ FROM effect_sql_migrations
162+ ORDER BY migration_id
163+ `;
164+ },
165+);
166+
130167 /**
131168 * Refuse to run a build whose migration history disagrees with the database.
132169 * Unknown newer IDs are intentionally allowed so a compatible older build can
133170 * inspect a database created by a newer release without rewriting its history.
134171 */
135172 export const validateMigrationIdentities = Effect.fn("validateMigrationIdentities")(function* () {
136− const sql = yield* SqlClient.SqlClient;
137− const tables = yield* sql<{ readonly name: string }>`
138− SELECT name
139− FROM sqlite_master
140− WHERE type = 'table' AND name = 'effect_sql_migrations'
141− `;
142− if (tables.length === 0) {
143− return;
144− }
145−
146173 const expectedNames = new Map<number, string>(
147174 migrationEntries.map(([migrationId, name]) => [migrationId, name]),
148175 );
149− const persisted = yield* sql<{
150− readonly migration_id: number;
151− readonly name: string;
152− }>`
153− SELECT migration_id, name
154− FROM effect_sql_migrations
155− ORDER BY migration_id
156− `;
176+ const persisted = yield* readPersistedMigrationIdentities();
157177
158178 for (const migration of persisted) {
159179 const expectedName = expectedNames.get(migration.migration_id);
@@ -167,6 +187,26 @@ export const validateMigrationIdentities = Effect.fn("validateMigrationIdentitie · 7 unmodified lines
167187 }
168188 });
169189
190+/**
191+ * Automatic activation is safe only when the target and database are already
192+ * on the same schema. A binary rollback cannot undo a migration, and an older
193+ * target cannot safely interpret migration IDs that it does not know about.
194+ */
195+export const validateAutomaticUpdateMigrationFrontier = Effect.fn(
196+ "validateAutomaticUpdateMigrationFrontier",
197+)(function* () {
198+ yield* validateMigrationIdentities();
199+ const persisted = yield* readPersistedMigrationIdentities();
200+ const databaseMigrationId = persisted.at(-1)?.migration_id ?? 0;
201+ const targetMigrationId = migrationEntries.at(-1)?.[0] ?? 0;
202+ if (databaseMigrationId !== targetMigrationId) {
203+ return yield* new AutomaticUpdateMigrationFrontierError({
204+ databaseMigrationId,
205+ targetMigrationId,
206+ });
207+ }
208+});
209+
170210 export interface RunMigrationsOptions {
171211 readonly toMigrationInclusive?: number | undefined;
172212 }
apps/server/src/server.test.ts+25−0
@@ -782,6 +782,7 @@ const buildAppUnderTest = (options?: { · 781 unmodified lines
782782 Layer.provide(
783783 Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({
784784 awaitCommandReady: Effect.void,
785+ awaitReady: Effect.void,
785786 markHttpListening: Effect.void,
786787 enqueueCommand: (effect) => effect,
787788 ...options?.layers?.serverRuntimeStartup,
@@ -1361,6 +1362,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 573 unmodified lines
13611362 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
13621363 );
13631364
1365+ it.effect("does not report update readiness before the runtime publishes ready", () =>
1366+ Effect.gen(function* () {
1367+ const runtimeReady = yield* Deferred.make<void>();
1368+ yield* buildAppUnderTest({
1369+ layers: {
1370+ serverRuntimeStartup: {
1371+ awaitReady: Deferred.await(runtimeReady),
1372+ },
1373+ },
1374+ });
1375+
1376+ const url = yield* getHttpServerUrl("/.well-known/t3/ready");
1377+ const responseFiber = yield* fetchEffect(url).pipe(Effect.forkScoped);
1378+ yield* Effect.yieldNow;
1379+ assert.isUndefined(responseFiber.pollUnsafe());
1380+
1381+ yield* Deferred.succeed(runtimeReady, undefined);
1382+ const response = yield* Fiber.join(responseFiber);
1383+ const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
1384+ assert.equal(response.status, 200);
1385+ assert.deepEqual(body, testEnvironmentDescriptor);
1386+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1387+ );
1388+
13641389 it.effect("compresses large JSON responses through the composed routes", () =>
13651390 Effect.gen(function* () {
13661391 const descriptor = {
apps/server/src/serverRuntimeStartup.ts+5−0
@@ -59,6 +59,7 @@ export class ServerRuntimeStartup extends Context.Service< · 58 unmodified lines
5959 ServerRuntimeStartup,
6060 {
6161 readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>;
62+ readonly awaitReady: Effect.Effect<void, ServerRuntimeStartupError>;
6263 readonly markHttpListening: Effect.Effect<void>;
6364 readonly enqueueCommand: <A, E>(
6465 effect: Effect.Effect<A, E>,
@@ -300,6 +301,7 @@ export const make = Effect.gen(function* () { · 235 unmodified lines
300301
301302 const commandGate = yield* makeCommandGate;
302303 const httpListening = yield* Deferred.make<void>();
304+ const runtimeReady = yield* Deferred.make<void, ServerRuntimeStartupError>();
303305 const reactorScope = yield* Scope.make("sequential");
304306
305307 yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
@@ -425,6 +427,7 @@ export const make = Effect.gen(function* () { · 119 unmodified lines
425427 });
426428 yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
427429 yield* commandGate.failCommandReady(error);
430+ yield* Deferred.fail(runtimeReady, error).pipe(Effect.orDie);
428431 return;
429432 }
430433
@@ -444,6 +447,7 @@ export const make = Effect.gen(function* () { · 13 unmodified lines
444447 },
445448 }),
446449 );
450+ yield* Deferred.succeed(runtimeReady, undefined).pipe(Effect.orDie);
447451
448452 yield* Effect.logDebug("startup phase: recording startup heartbeat");
449453 yield* launchStartupHeartbeat;
@@ -470,6 +474,7 @@ export const make = Effect.gen(function* () { · 20 unmodified lines
470474
471475 return {
472476 awaitCommandReady: commandGate.awaitCommandReady,
477+ awaitReady: Deferred.await(runtimeReady),
473478 markHttpListening: Deferred.succeed(httpListening, undefined),
474479 enqueueCommand: commandGate.enqueueCommand,
475480 } satisfies ServerRuntimeStartup["Service"];
docs/internals/server-updates.md+9−4
@@ -90,9 +90,12 @@ successfully. Boot-service setup and self-update share the same process-wide ins · 89 unmodified lines
9090 they cannot mutate a pinned runtime concurrently.
9191
9292 Before any restart, the current Node executable runs the replacement with `--version`, then asks
93−the target artifact to validate its migration identities against the live database in read-only
94−mode. A failed install, version preflight, or compatibility check leaves the current server running.
95−Targets old enough to lack the compatibility command are not eligible for remote activation.
93+the target artifact to validate the live database in read-only mode. Automatic activation requires
94+the target and database to be on the exact same migration frontier. This rejects both downgrades and
95+updates that would migrate the database because a binary rollback cannot undo a schema change. Use
96+the manual host service update path for those releases. A failed install, version preflight, or
97+compatibility check leaves the current server running. Targets old enough to lack the compatibility
98+command are not eligible for remote activation.
9699
97100 Candidate cleanup is narrower than "any failed preflight". The candidate runtime is removed only when
98101 the preflight process actually completes and reports a bad exit code or the wrong version: that is
@@ -118,7 +121,9 @@ For `boot-service`, the request process writes a typed plan but leaves its live · 19 unmodified lines
118121 starts a fixed-name `t3code-self-update.service` transient unit with `systemd-run --user`; the
119122 transient unit has a separate cgroup and acts as the cross-process update lock. After a short grace
120123 period it atomically installs the candidate unit, reloads systemd, performs a blocking restart, and
121−probes the public environment descriptor for the exact target version. If activation or readiness
124+probes the public runtime-readiness endpoint for the exact target version. The endpoint does not
125+respond until the server's command runtime is usable and its lifecycle `ready` event has been
126+published, so an HTTP listener alone cannot make an activation healthy. If activation or readiness
122127 fails, the helper restores the previous unit, clears systemd's failed state, restarts it, and proves
123128 the previous version is ready. The plan and latest receipt live under the environment state
124129 directory's `self-update` folder for diagnosis.
docs/operations/release.md+17−0
@@ -190,6 +190,23 @@ connect the new client to a server on the previous version and verify that the u · 189 unmodified lines
190190 reconnects to the matching server. Test one automatic path and the manual or desktop-managed
191191 guidance when those environments are available.
192192
193+The automatic boot-service path also requires a real systemd user-manager acceptance run. Use a
194+disposable T3 home and uniquely named host and helper units, never the developer's daily
195+`t3code.service`. Exercise both outcomes:
196+
197+1. A same-migration-frontier candidate becomes runtime-ready, the helper survives the host-unit
198+ restart, and the receipt reaches `healthy` with a new runtime PID and the exact target version.
199+2. A candidate that binds HTTP but never becomes runtime-ready times out, the previous unit is
200+ restored, and the receipt reaches `rolled-back` with the old version command-ready again.
201+
202+Treat the systemd run as a release blocker when the update lifecycle changes. CI jobs without a
203+user manager may skip it, but a Linux release host must record the run before publishing.
204+
205+```bash
206+T3_SYSTEMD_SELF_UPDATE_ACCEPTANCE=1 vp test run \
207+ apps/server/src/cloud/systemdSelfUpdate.acceptance.test.ts
208+```
209+
193210 ## Desktop auto-update notes
194211
195212 - Updater runtime: `apps/desktop/src/updates/DesktopUpdates.ts`.
packages/contracts/src/environmentHttp.ts+11−5
@@ -371,11 +371,17 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ · 370 unmodified lines
371371 });
372372 export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type;
373373
374−export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add(
375− HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", {
376− success: ExecutionEnvironmentDescriptor,
377− }),
378−) {}
374+export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata")
375+ .add(
376+ HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", {
377+ success: ExecutionEnvironmentDescriptor,
378+ }),
379+ )
380+ .add(
381+ HttpApiEndpoint.get("ready", "/.well-known/t3/ready", {
382+ success: ExecutionEnvironmentDescriptor,
383+ }),
384+ ) {}
379385
380386 export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
381387 .add(

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t