Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
const control: {
failCommand: string | undefined;
stateAfterStop?: string;
onCommand?: (command: string) => Effect.Effect<void>;
linger: string;
enabled: boolean;
active: boolean;
Expand All @@ -161,6 +162,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
) {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
if (control.onCommand) yield* control.onCommand(command);
invocations.push(input);
timeouts.set(command, input.timeout);
const failed = command === control.failCommand;
Expand All @@ -180,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
return {
stdout:
input.args[0] === "--version"
? `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n`
? // The runtime under test reports the version of the directory it
// was launched from, like the real executable.
`t3 v${/[\\/]versions[\\/]([^\\/]+)[\\/]/.exec(input.command)?.[1] ?? "1.2.3"}\n`
: input.command === "loginctl" && input.args[0] === "show-user"
? `${control.linger}\n`
: input.args[1] === "is-enabled"
Expand Down Expand Up @@ -543,6 +547,115 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
}),
);

it.effect.each(["linux", "darwin"] as const)(
"refuses a deferred legacy install before commands or filesystem changes on %s",
(platform) =>
Effect.gen(function* () {
const { service, fs, statePath, commands, makeService } = yield* makeHarness(platform);
const path = yield* Path.Path;
const plan = yield* service.install();
const unitBefore = yield* fs.readFileString(plan.unitPath);
const legacyState = '{"protocol":2,"activeVersion":"1.2.3"}';
const restartPending = path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE);
const newer = yield* makeService(undefined, "1.2.4");
yield* fs.writeFileString(statePath, legacyState);
yield* fs.writeFileString(restartPending, "1.2.3\n");
yield* fs.remove(path.dirname(plan.logPath), { recursive: true });
commands.length = 0;

const result = yield* newer.install({ start: false }).pipe(Effect.result);

expect(yield* fs.readFileString(statePath)).toBe(legacyState);
expect(result).toMatchObject({
_tag: "Failure",
failure: {
_tag: "BootServiceMigrationRequiredError",
message: expect.stringContaining("t3 update --yes"),
},
});
expect(yield* fs.readFileString(plan.unitPath)).toBe(unitBefore);
expect(yield* fs.readFileString(restartPending)).toBe("1.2.3\n");
expect(yield* fs.exists(path.dirname(plan.logPath))).toBe(false);
expect(commands).toEqual([]);
}),
);

it.effect.each(["linux", "darwin"] as const)(
"rechecks legacy state after preparing a deferred install on %s",
(platform) =>
Effect.gen(function* () {
const { service, fs, statePath, commands, makeService, control } =
yield* makeHarness(platform);
const path = yield* Path.Path;
const plan = yield* service.install();
const unitBefore = yield* fs.readFileString(plan.unitPath);
const legacyState = '{"protocol":2,"activeVersion":"1.2.3"}';
const newer = yield* makeService(undefined, "1.2.4");
control.onCommand = (command) =>
command.endsWith(" --version")
? fs.writeFileString(statePath, legacyState).pipe(Effect.orDie)
: Effect.void;
commands.length = 0;

const result = yield* newer.install({ start: false }).pipe(Effect.result);

expect(commands.some((command) => command.endsWith(" --version"))).toBe(true);
expect(yield* fs.readFileString(statePath)).toBe(legacyState);
expect(result).toMatchObject({
_tag: "Failure",
failure: {
_tag: "BootServiceMigrationRequiredError",
message: expect.stringContaining("t3 update --yes"),
},
});
expect(yield* fs.readFileString(plan.unitPath)).toBe(unitBefore);
expect(
yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
).toBe(false);
expect(
commands.filter(
(command) =>
command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") &&
!command.includes("show-environment"),
),
).toEqual([]);
}),
);

it.effect.each(["linux", "darwin"] as const)(
"stops a legacy service before replacing its state and unit on %s",
(platform) =>
Effect.gen(function* () {
const { service, fs, statePath, commands, makeService, control } =
yield* makeHarness(platform);
const plan = yield* service.install();
const unitBefore = yield* fs.readFileString(plan.unitPath);
const legacyState = '{"protocol":2,"activeVersion":"1.2.3"}';
const newer = yield* makeService(undefined, "1.2.4");
yield* fs.writeFileString(statePath, legacyState);
const stop =
platform === "linux"
? "systemctl --user stop t3code.service"
: "launchctl bootout --wait gui/501/com.t3tools.t3code.service";
control.onCommand = (command) =>
Effect.gen(function* () {
if (command !== stop) return;
expect(yield* fs.readFileString(statePath)).toBe(legacyState);
expect(yield* fs.readFileString(plan.unitPath)).toBe(unitBefore);
}).pipe(Effect.orDie);
commands.length = 0;

yield* newer.install();

expect(commands).toContain(stop);
expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.4",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
}),
);

it.effect("install with start=false keeps the marker when a later write fails", () =>
Effect.gen(function* () {
const { service, fs, statePath, makeService } = yield* makeHarness();
Expand Down
37 changes: 31 additions & 6 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
SERVICE_RESTART_PENDING_FILE,
SERVICE_STATE_FILE,
compareExactServiceVersions,
parseLegacyServiceState,
parseServiceState,
serviceStateActiveVersion,
serviceStateHasPendingUpdate,
Expand Down Expand Up @@ -469,6 +470,15 @@ export class BootServiceUpdatePendingError extends Schema.TaggedError<BootServic
}
}

export class BootServiceMigrationRequiredError extends Schema.TaggedError<BootServiceMigrationRequiredError>()(
"BootServiceMigrationRequiredError",
{},
) {
override get message(): string {
return "Cannot defer an install for legacy service state. Rerun `t3 update --yes` or run `t3 service install` to replace and restart the service.";
}
}

export class BootServiceDowngradeRefusedError extends Schema.TaggedError<BootServiceDowngradeRefusedError>()(
"BootServiceDowngradeRefusedError",
{
Expand All @@ -487,6 +497,7 @@ export type BootServiceError =
| BootServiceInstallError
| BootServicePrerequisiteError
| BootServiceUpdatePendingError
| BootServiceMigrationRequiredError
| BootServiceDowngradeRefusedError;

export interface BootServiceStatus {
Expand Down Expand Up @@ -731,6 +742,20 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
readonly start?: boolean;
}) {
const manager = yield* requireManager;
const start = options?.start !== false;
// A deferred install cannot stop a launcher that may be adopting this state.
if (
!start &&
(yield* fs
.exists(unitPath)
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))
) {
const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
if (Option.isSome(previousStateText) && parseLegacyServiceState(previousStateText.value)) {
return yield* new BootServiceMigrationRequiredError();
}
}

yield* fs
.makeDirectory(input.logsDir, { recursive: true })
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
Expand Down Expand Up @@ -794,12 +819,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
const installed = yield* fs
.exists(unitPath)
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
// With start=false the service keeps running while its files change. The
// launcher reads the state file once at startup and the unit only matters
// on the next start, so that is safe as long as the launcher is not in the
// middle of a remote update, which is the one time it writes the state file
// itself.
const start = options?.start !== false;
if (installed && start && runningInsideService && manager.kind === "systemd") {
// Stopping the unit from inside it kills this process before the start.
// A transient unit runs the same install from outside, stop first, so it
Expand All @@ -822,6 +841,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
]);
return { ...plan, handedOff: true };
}
// A started install stops the launcher before replacing its state. A
// deferred install rechecks after preparation, which can overlap startup
// migration or a remote update.
if (installed && start) {
yield* runSteps(manager.stop);
}
Expand All @@ -830,6 +852,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
if (installed) {
const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
if (Option.isSome(previousStateText)) {
if (!start && parseLegacyServiceState(previousStateText.value)) {
return yield* new BootServiceMigrationRequiredError();
}
if (serviceStateHasPendingUpdate(previousStateText.value)) {
return yield* new BootServiceUpdatePendingError();
}
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/cloud/serviceProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,24 @@ export function parseServiceState(value: string): ServiceState | undefined {
}
}

export function parseLegacyServiceState(value: string) {
try {
const parsed: unknown = JSON.parse(value);
if (
!isRecord(parsed) ||
Object.keys(parsed).length !== 2 ||
parsed.protocol !== 2 ||
typeof parsed.activeVersion !== "string" ||
!isExactServiceVersion(parsed.activeVersion)
) {
return undefined;
}
return { protocol: 2, activeVersion: parsed.activeVersion } as const;
} catch {
return undefined;
}
}

export function serviceStateHasPendingUpdate(value: string): boolean {
try {
const parsed: unknown = JSON.parse(value);
Expand Down
Loading
Loading