Skip to content
Merged
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
52 changes: 49 additions & 3 deletions apps/server/src/cli/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { serviceCommand } from "./service.ts";

afterEach(() => vi.restoreAllMocks());

vi.mock("../cloud/selfUpdate.ts", async () => {
const Effect = await import("effect/Effect");
return { updateStoppedRuntime: () => Effect.void };
});

const status = {
supported: true,
installed: true,
Expand Down Expand Up @@ -55,21 +60,59 @@ function makeTestService(
}

it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
it.effect.each([
{ label: "missing", installed: false },
{ label: "foreign", installed: true, installedBaseDir: "/another/t3-home" },
])("update-stopped refuses a $label unit before touching runtime state", (input) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
const { service, installOptions, restarts } = makeTestService({ ...status, ...input });
vi.spyOn(BootService, "layer").mockReturnValue(
Layer.succeed(BootService.BootService, service),
);
const result = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
"update-stopped",
"1.2.3",
"--base-dir",
baseDir,
"--unit",
"crew.service",
]).pipe(
Effect.provideService(HostProcess.Environment, {}),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
Effect.result,
);
expect(result).toMatchObject({
_tag: "Failure",
failure: {
_tag: "BootServiceCommandError",
step: "checking the selected service unit and T3 home",
},
});
expect(yield* fs.exists(`${baseDir}/runtime`)).toBe(false);
expect(installOptions).toEqual([]);
expect(restarts).toEqual([]);
}),
);

it.effect.each(["install", "update"] as const)(
"%s refuses a downgrade before changing the service",
(command) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
const { service, installOptions } = makeTestService(newerServiceStatus);
vi.spyOn(BootService, "layer").mockReturnValue(
Layer.succeed(BootService.BootService, service),
);
const serviceLayer = vi
.spyOn(BootService, "layer")
.mockReturnValue(Layer.succeed(BootService.BootService, service));

const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
command,
"--base-dir",
baseDir,
"--unit",
"crew.service",
]).pipe(
Effect.provideService(HostProcess.Environment, {}),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
Expand All @@ -82,6 +125,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands
targetVersion: packageJson.version,
});
expect(installOptions).toEqual([]);
expect(serviceLayer).toHaveBeenCalledWith(
expect.objectContaining({ baseDir, unit: "crew.service" }),
);
}),
);

Expand Down
81 changes: 71 additions & 10 deletions apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,27 @@ import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli";
import { FetchHttpClient } from "effect/http";

import packageJson from "../../package.json" with { type: "json" };
import * as BootService from "../cloud/bootService.ts";
import * as ServiceProtection from "../cloud/serviceProtection.ts";
import { compareExactServiceVersions, SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts";
import * as ServerConfig from "../config.ts";
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as ProcessRunner from "../processRunner.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
import { resolveEntryPath } from "./invocation.ts";

export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
export const layer = (config: ServerConfig.ServerConfig["Service"], unit?: string) =>
BootService.layer({
baseDir: config.baseDir,
logsDir: config.logsDir,
cliVersion: packageJson.version,
...(unit === undefined ? {} : { unit }),
}).pipe(Layer.provide(ProcessRunner.layer), Layer.provide(FetchHttpClient.layer));

export type ServiceReconcileResult =
Expand Down Expand Up @@ -154,7 +157,8 @@ function formatServiceStatus(
}
const installedVersion = status.installedVersion ?? cliVersion;
const problems = (status.problems ?? []).map(
(problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`,
(problem) =>
` [${problem}] ${BootService.formatBootServiceProblem(problem, status.unitPath.split("/").at(-1))}`,
);
if (
!status.current &&
Expand Down Expand Up @@ -183,16 +187,31 @@ function formatServiceStatus(
}

const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E, R>(
flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
flags: {
readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"];
readonly unit?: Option.Option<string>;
},
run: Effect.Effect<A, E, R>,
) {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveCliAuthConfig(flags, logLevel);
return yield* run.pipe(Effect.provide(Layer.merge(layer(config), ServerConfig.layer(config))));
const unit = flags.unit === undefined ? undefined : Option.getOrUndefined(flags.unit);
return yield* run.pipe(
Effect.provide(Layer.merge(layer(config, unit), ServerConfig.layer(config))),
);
});

const serviceReconcileFlags = {
const serviceFlags = {
...projectLocationFlags,
unit: Flag.String("unit").pipe(
Flag.withDescription("Select the systemd user service unit."),
Flag.withSchema(BootService.BootServiceUnitName),
Flag.optional,
),
};

const serviceReconcileFlags = {
...serviceFlags,
allowDowngrade: Flag.Boolean("allow-downgrade").pipe(
Flag.withDescription("Allow replacing a newer installed service with this older CLI version."),
Flag.withDefault(false),
Expand Down Expand Up @@ -246,7 +265,43 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
),
);

const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(
const serviceUpdateStoppedCommand = Command.make("update-stopped", {
...serviceFlags,
targetVersion: Argument.String("target-version"),
}).pipe(
Command.withDescription("Update an already stopped service to an exact release version."),
Command.withHandler((flags) =>
runServiceCommand(
flags,
Effect.gen(function* () {
const status = yield* (yield* BootService.BootService).status;
const { baseDir } = yield* ServerConfig.ServerConfig;
const path = yield* Path.Path;
if (
!status.installed ||
status.installedBaseDir === undefined ||
path.resolve(status.installedBaseDir) !== path.resolve(baseDir)
) {
return yield* new BootService.BootServiceCommandError({
step: "checking the selected service unit and T3 home",
cause: new Error("The selected service unit is missing or serves another T3 home."),
});
}
return yield* Effect.promise(() => import("../cloud/selfUpdate.ts"));
}).pipe(
Effect.flatMap(({ updateStoppedRuntime }) =>
updateStoppedRuntime({ targetVersion: flags.targetVersion }),
),
Comment thread
EvilWeasel marked this conversation as resolved.
Resolved
Effect.provide(
Layer.mergeAll(ProcessRunner.layer, FetchHttpClient.layer, ManagedPolicy.layer),
),
Effect.andThen(Console.log(`Service active version: t3@${flags.targetVersion}`)),
),
),
),
);

const serviceRestartCommand = Command.make("restart", serviceFlags).pipe(
Command.withDescription(
"Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.",
),
Expand All @@ -271,7 +326,7 @@ const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe
),
);

const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
const serviceUninstallCommand = Command.make("uninstall", serviceFlags).pipe(
Command.withDescription("Stop and remove the T3 Code background service."),
Command.withHandler((flags) =>
runServiceCommand(
Expand All @@ -287,17 +342,22 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).
),
);

const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
const serviceStatusCommand = Command.make("status", serviceFlags).pipe(
Command.withDescription("Show whether the T3 Code background service is installed."),
Command.withHandler((flags) =>
runServiceCommand(
flags,
Effect.gen(function* () {
const service = yield* BootService.BootService;
const path = yield* Path.Path;
const { baseDir } = yield* ServerConfig.ServerConfig;
const status = yield* service.status;
const protection =
status.installed && (yield* HostProcess.Platform) === "linux"
status.installed &&
status.installedBaseDir !== undefined &&
path.resolve(status.installedBaseDir) === path.resolve(baseDir) &&
path.basename(status.unitPath) === BootService.BOOT_SERVICE_UNIT_FILE &&
(yield* HostProcess.Platform) === "linux"
? yield* ServiceProtection.readProtection(path.dirname(status.unitPath)).pipe(
Effect.provide(ProcessRunner.layer),
)
Expand Down Expand Up @@ -426,5 +486,6 @@ export const serviceCommand = Command.make("service").pipe(
serviceStatusCommand,
serviceProtectCommand,
serviceUpdateCommand,
serviceUpdateStoppedCommand,
]),
);
Loading
Loading