@gajae-gateway/cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.d.ts +38 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +665 -66
- package/dist/restart-stack.d.ts +110 -0
- package/dist/restart-stack.d.ts.map +1 -0
- package/dist/services.d.ts +37 -1
- package/dist/services.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/main.ts +358 -51
- package/src/restart-stack.ts +429 -0
- package/src/services.ts +148 -15
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
currentPlatform,
|
|
7
|
+
launchAgentPlistPath,
|
|
8
|
+
restartOrder,
|
|
9
|
+
restartStackCommands,
|
|
10
|
+
type ServicePlatform,
|
|
11
|
+
serviceSpecs,
|
|
12
|
+
systemdUnitDir,
|
|
13
|
+
systemdUnitName,
|
|
14
|
+
systemdUnitPath,
|
|
15
|
+
} from "./services";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `gajaeway ops restart-stack` (issue #54).
|
|
19
|
+
*
|
|
20
|
+
* A persona turn that restarts the gateway is killed with it, so running the
|
|
21
|
+
* ordered restart inline stopped between the gateway and its adapters and left
|
|
22
|
+
* the adapters on the old binary while the caller reported success. The
|
|
23
|
+
* sequence therefore runs in a detached supervisor (its own session, so the
|
|
24
|
+
* caller's process group and the service manager's job kill do not reach it),
|
|
25
|
+
* verifies every service against its deployed binary, and records a receipt
|
|
26
|
+
* under `$GAJAEWAY_HOME` that the next turn reads with `--status`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type RestartStepResult = "pending" | "ok" | "stale" | "failed" | "skipped";
|
|
30
|
+
export type RestartState = "queued" | "running" | "ok" | "stale" | "failed";
|
|
31
|
+
|
|
32
|
+
export interface RestartStep {
|
|
33
|
+
readonly label: string;
|
|
34
|
+
/** Absent on systemd, where the gateway unit's restart carries its dependents. */
|
|
35
|
+
command?: readonly string[];
|
|
36
|
+
exitStatus?: number;
|
|
37
|
+
result: RestartStepResult;
|
|
38
|
+
pid?: number;
|
|
39
|
+
processStartedAt?: string;
|
|
40
|
+
binary?: string;
|
|
41
|
+
binaryModifiedAt?: string;
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RestartReceipt {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
state: RestartState;
|
|
48
|
+
readonly platform: ServicePlatform;
|
|
49
|
+
readonly requestedAt: string;
|
|
50
|
+
supervisorPid?: number;
|
|
51
|
+
finishedAt?: string;
|
|
52
|
+
readonly steps: RestartStep[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A running service process as the service manager and `ps` report it. */
|
|
56
|
+
export interface ServiceProcess {
|
|
57
|
+
readonly pid: number;
|
|
58
|
+
/** Epoch ms; `ps` reports whole seconds. */
|
|
59
|
+
readonly startedAt: number;
|
|
60
|
+
readonly binary: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type CommandRunner = (command: readonly string[]) => number | PromiseLike<number>;
|
|
64
|
+
export type ServiceProbe = (label: string) => Promise<ServiceProcess | undefined>;
|
|
65
|
+
export type IsServiceInstalled = (label: string) => Promise<boolean>;
|
|
66
|
+
|
|
67
|
+
export interface RunRestartOptions {
|
|
68
|
+
readonly home: string;
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly platform?: ServicePlatform;
|
|
71
|
+
readonly uid?: number;
|
|
72
|
+
readonly runner?: CommandRunner;
|
|
73
|
+
readonly probe?: ServiceProbe;
|
|
74
|
+
/** Checks if a service is installed; if not provided, defaults to checking the service manager's files. */
|
|
75
|
+
readonly isServiceInstalled?: IsServiceInstalled;
|
|
76
|
+
/** Epoch ms modification time of a deployed binary. */
|
|
77
|
+
readonly binaryModifiedAt?: (path: string) => Promise<number>;
|
|
78
|
+
readonly now?: () => number;
|
|
79
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
80
|
+
/** How long one service may take to come back verified; covers the gateway's ordered shutdown. */
|
|
81
|
+
readonly verifyTimeoutMs?: number;
|
|
82
|
+
readonly pollMs?: number;
|
|
83
|
+
/** Environment variables; used to determine systemd unit dir on linux. Defaults to process.env. */
|
|
84
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
85
|
+
/** LaunchAgents directory on darwin. Defaults to ~/Library/LaunchAgents. */
|
|
86
|
+
readonly launchAgentsDir?: string;
|
|
87
|
+
/** systemd unit directory on linux. Defaults to $XDG_CONFIG_HOME/systemd/user. */
|
|
88
|
+
readonly unitDir?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const RECEIPT_FILE = "restart-stack.json";
|
|
92
|
+
const VERIFY_TIMEOUT_MS = 90_000;
|
|
93
|
+
const POLL_MS = 1_000;
|
|
94
|
+
|
|
95
|
+
export function restartReceiptPath(home: string): string {
|
|
96
|
+
return join(home, RECEIPT_FILE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readRestartReceipt(home: string): Promise<RestartReceipt | undefined> {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = await readFile(restartReceiptPath(home), "utf8");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return JSON.parse(raw) as RestartReceipt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeReceipt(home: string, receipt: RestartReceipt): Promise<void> {
|
|
111
|
+
await mkdir(home, { recursive: true });
|
|
112
|
+
const path = restartReceiptPath(home);
|
|
113
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
114
|
+
await writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
115
|
+
await rename(temporary, path);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function newReceipt(id: string, platform: ServicePlatform, uid: number | undefined, now: number): RestartReceipt {
|
|
119
|
+
const commands = restartStackCommands(platform, uid);
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
state: "queued",
|
|
123
|
+
platform,
|
|
124
|
+
requestedAt: new Date(now).toISOString(),
|
|
125
|
+
steps: restartOrder().map((spec, index) => ({
|
|
126
|
+
label: spec.label,
|
|
127
|
+
...(platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {}),
|
|
128
|
+
result: "pending",
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function defaultCommandRunner(command: readonly string[]): Promise<number> {
|
|
134
|
+
const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
135
|
+
return await child.exited;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function capture(command: readonly string[]): Promise<string | undefined> {
|
|
139
|
+
const child = Bun.spawn([...command], {
|
|
140
|
+
stdin: "ignore",
|
|
141
|
+
stdout: "pipe",
|
|
142
|
+
stderr: "ignore",
|
|
143
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
144
|
+
});
|
|
145
|
+
const output = await new Response(child.stdout).text();
|
|
146
|
+
return (await child.exited) === 0 ? output : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Reads the live pid from the service manager, then its start time and executable from `ps`. */
|
|
150
|
+
export function defaultServiceProbe(platform: ServicePlatform): ServiceProbe {
|
|
151
|
+
return async (label) => {
|
|
152
|
+
const spec = serviceSpecs().find((candidate) => candidate.label === label);
|
|
153
|
+
if (spec === undefined) throw new Error(`unknown service label: ${label}`);
|
|
154
|
+
let pid: number | undefined;
|
|
155
|
+
if (platform === "darwin") {
|
|
156
|
+
const listed = await capture(["launchctl", "list", label]);
|
|
157
|
+
const match = listed?.match(/"PID"\s*=\s*(\d+);/);
|
|
158
|
+
pid = match ? Number(match[1]) : undefined;
|
|
159
|
+
} else {
|
|
160
|
+
const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
|
|
161
|
+
pid = shown === undefined ? undefined : Number(shown.trim());
|
|
162
|
+
}
|
|
163
|
+
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return undefined;
|
|
164
|
+
const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
|
|
165
|
+
const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
|
|
166
|
+
if (lstart === undefined || args === undefined) return undefined;
|
|
167
|
+
const startedAt = Date.parse(lstart.trim());
|
|
168
|
+
if (!Number.isFinite(startedAt)) return undefined;
|
|
169
|
+
// The service definitions exec `<bin-dir>/<binary> <fixed args>`; stripping
|
|
170
|
+
// the known arguments keeps a bin dir containing spaces intact.
|
|
171
|
+
const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
|
|
172
|
+
const line = args.trim();
|
|
173
|
+
const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
|
|
174
|
+
return { pid, startedAt, binary };
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function defaultBinaryModifiedAt(path: string): Promise<number> {
|
|
179
|
+
return (await stat(path)).mtimeMs;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Checks if a service is installed by verifying the service manager's definition file exists. */
|
|
183
|
+
function defaultIsServiceInstalled(options: {
|
|
184
|
+
readonly platform: ServicePlatform;
|
|
185
|
+
readonly env: NodeJS.ProcessEnv;
|
|
186
|
+
readonly launchAgentsDir?: string;
|
|
187
|
+
readonly unitDir?: string;
|
|
188
|
+
}): IsServiceInstalled {
|
|
189
|
+
return async (label: string) => {
|
|
190
|
+
const spec = serviceSpecs().find((s) => s.label === label);
|
|
191
|
+
if (spec === undefined) return false;
|
|
192
|
+
|
|
193
|
+
if (options.platform === "darwin") {
|
|
194
|
+
const userHome = options.env.HOME || homedir();
|
|
195
|
+
const agentsDir = options.launchAgentsDir || join(userHome, "Library", "LaunchAgents");
|
|
196
|
+
const plistPath = launchAgentPlistPath(spec, agentsDir);
|
|
197
|
+
try {
|
|
198
|
+
await access(plistPath);
|
|
199
|
+
return true;
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// linux
|
|
206
|
+
const userHome = options.env.HOME || homedir();
|
|
207
|
+
const unitDirPath = options.unitDir || systemdUnitDir(options.env, userHome);
|
|
208
|
+
const unitPath = systemdUnitPath(spec, unitDirPath);
|
|
209
|
+
try {
|
|
210
|
+
await access(unitPath);
|
|
211
|
+
return true;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function floorSecond(ms: number): number {
|
|
219
|
+
return Math.floor(ms / 1_000) * 1_000;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Runs the ordered restart, verifying each service before the next command,
|
|
224
|
+
* and persists the receipt after every transition. The first service that is
|
|
225
|
+
* stale or missing aborts the remainder.
|
|
226
|
+
*/
|
|
227
|
+
export async function runRestartStack(options: RunRestartOptions): Promise<RestartReceipt> {
|
|
228
|
+
const platform = options.platform ?? currentPlatform();
|
|
229
|
+
const runner = options.runner ?? defaultCommandRunner;
|
|
230
|
+
const probe = options.probe ?? defaultServiceProbe(platform);
|
|
231
|
+
const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
|
|
232
|
+
const isInstalled =
|
|
233
|
+
options.isServiceInstalled ??
|
|
234
|
+
defaultIsServiceInstalled({
|
|
235
|
+
platform,
|
|
236
|
+
env: options.env ?? process.env,
|
|
237
|
+
launchAgentsDir: options.launchAgentsDir,
|
|
238
|
+
unitDir: options.unitDir,
|
|
239
|
+
});
|
|
240
|
+
const now = options.now ?? Date.now;
|
|
241
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
242
|
+
const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
243
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
244
|
+
|
|
245
|
+
const existing = await readRestartReceipt(options.home);
|
|
246
|
+
const receipt =
|
|
247
|
+
existing?.id === options.id && existing.state === "queued"
|
|
248
|
+
? existing
|
|
249
|
+
: newReceipt(options.id, platform, options.uid, now());
|
|
250
|
+
receipt.state = "running";
|
|
251
|
+
receipt.supervisorPid = process.pid;
|
|
252
|
+
await writeReceipt(options.home, receipt);
|
|
253
|
+
|
|
254
|
+
let issuedAt = now();
|
|
255
|
+
for (const step of receipt.steps) {
|
|
256
|
+
if (receipt.state !== "running") {
|
|
257
|
+
step.result = "skipped";
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
// Check if the service is installed before trying to restart it
|
|
262
|
+
const installed = await isInstalled(step.label);
|
|
263
|
+
if (!installed) {
|
|
264
|
+
step.result = "skipped";
|
|
265
|
+
step.detail = "service is not installed";
|
|
266
|
+
await writeReceipt(options.home, receipt);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (step.command !== undefined) {
|
|
271
|
+
issuedAt = now();
|
|
272
|
+
const status = await runner(step.command);
|
|
273
|
+
step.exitStatus = status;
|
|
274
|
+
if (status !== 0) {
|
|
275
|
+
step.result = "failed";
|
|
276
|
+
step.detail = `${step.command.join(" ")} exited with status ${status}`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (step.result === "pending") await verifyStep(step, issuedAt);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
step.result = "failed";
|
|
282
|
+
step.detail = error instanceof Error ? error.message : String(error);
|
|
283
|
+
}
|
|
284
|
+
if (step.result !== "ok" && step.result !== "skipped") {
|
|
285
|
+
receipt.state = step.result === "stale" ? "stale" : "failed";
|
|
286
|
+
}
|
|
287
|
+
await writeReceipt(options.home, receipt);
|
|
288
|
+
}
|
|
289
|
+
if (receipt.state === "running") receipt.state = "ok";
|
|
290
|
+
receipt.finishedAt = new Date(now()).toISOString();
|
|
291
|
+
await writeReceipt(options.home, receipt);
|
|
292
|
+
return receipt;
|
|
293
|
+
|
|
294
|
+
async function verifyStep(step: RestartStep, since: number): Promise<void> {
|
|
295
|
+
const deadline = now() + timeoutMs;
|
|
296
|
+
for (;;) {
|
|
297
|
+
const running = await probe(step.label);
|
|
298
|
+
let binaryModifiedAt = 0;
|
|
299
|
+
if (running !== undefined) {
|
|
300
|
+
binaryModifiedAt = await modifiedAt(running.binary);
|
|
301
|
+
step.pid = running.pid;
|
|
302
|
+
step.processStartedAt = new Date(running.startedAt).toISOString();
|
|
303
|
+
step.binary = running.binary;
|
|
304
|
+
step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
|
|
305
|
+
// `ps` has one-second resolution, so the bound is floored to match.
|
|
306
|
+
if (running.startedAt >= floorSecond(Math.max(binaryModifiedAt, since))) {
|
|
307
|
+
step.result = "ok";
|
|
308
|
+
delete step.detail;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (now() >= deadline) {
|
|
313
|
+
if (running === undefined) {
|
|
314
|
+
step.result = "failed";
|
|
315
|
+
step.detail = "no running process after restart";
|
|
316
|
+
} else {
|
|
317
|
+
step.result = "stale";
|
|
318
|
+
step.detail =
|
|
319
|
+
running.startedAt < binaryModifiedAt
|
|
320
|
+
? "process started before the deployed binary was modified"
|
|
321
|
+
: "process was not restarted by this sequence";
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await sleep(pollMs);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface LaunchRestartOptions {
|
|
331
|
+
readonly home: string;
|
|
332
|
+
readonly platform?: ServicePlatform;
|
|
333
|
+
readonly uid?: number;
|
|
334
|
+
/** Argv that re-enters the CLI; `--run <id>` is appended. */
|
|
335
|
+
readonly worker?: readonly string[];
|
|
336
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The argv that runs this CLI again, whether compiled or run from source. */
|
|
340
|
+
export function cliCommand(): string[] {
|
|
341
|
+
return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function alive(pid: number | undefined): boolean {
|
|
345
|
+
if (pid === undefined) return false;
|
|
346
|
+
try {
|
|
347
|
+
process.kill(pid, 0);
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Queues a restart and hands it to a detached supervisor, returning as soon as
|
|
356
|
+
* the supervisor is spawned so the caller may be killed by the restart itself.
|
|
357
|
+
*/
|
|
358
|
+
export async function launchRestartStack(
|
|
359
|
+
options: LaunchRestartOptions,
|
|
360
|
+
): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
|
|
361
|
+
const platform = options.platform ?? currentPlatform();
|
|
362
|
+
const existing = await readRestartReceipt(options.home);
|
|
363
|
+
if (existing?.state === "running" && alive(existing.supervisorPid))
|
|
364
|
+
throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
|
|
365
|
+
const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
|
|
366
|
+
await writeReceipt(options.home, receipt);
|
|
367
|
+
const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
|
|
368
|
+
const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
|
|
369
|
+
detached: true,
|
|
370
|
+
stdin: "ignore",
|
|
371
|
+
stdout: "ignore",
|
|
372
|
+
stderr: "ignore",
|
|
373
|
+
env: { ...(options.env ?? process.env), GAJAEWAY_HOME: options.home },
|
|
374
|
+
});
|
|
375
|
+
child.unref();
|
|
376
|
+
return { receipt, supervisorPid: child.pid };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A new session escapes the caller's process group, which is enough under
|
|
381
|
+
* launchd. Under systemd the caller usually lives in a service cgroup that the
|
|
382
|
+
* restart it requests may stop, so the supervisor is started as its own
|
|
383
|
+
* transient user unit instead.
|
|
384
|
+
*/
|
|
385
|
+
export function supervisorArgv(
|
|
386
|
+
platform: ServicePlatform,
|
|
387
|
+
worker: readonly string[],
|
|
388
|
+
home: string,
|
|
389
|
+
id: string,
|
|
390
|
+
): string[] {
|
|
391
|
+
if (platform === "darwin") return [...worker];
|
|
392
|
+
return [
|
|
393
|
+
"systemd-run",
|
|
394
|
+
"--user",
|
|
395
|
+
"--collect",
|
|
396
|
+
"--quiet",
|
|
397
|
+
`--unit=gajaeway-restart-stack-${id}`,
|
|
398
|
+
`--setenv=GAJAEWAY_HOME=${home}`,
|
|
399
|
+
"--",
|
|
400
|
+
...worker,
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The receipt's state, except that an unfinished sequence whose supervisor is
|
|
406
|
+
* gone is reported as interrupted rather than left looking in progress.
|
|
407
|
+
*/
|
|
408
|
+
export function effectiveRestartState(receipt: RestartReceipt): RestartState | "interrupted" {
|
|
409
|
+
if (receipt.state === "running" && !alive(receipt.supervisorPid)) return "interrupted";
|
|
410
|
+
return receipt.state;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function renderRestartReceipt(receipt: RestartReceipt): string[] {
|
|
414
|
+
const lines = [
|
|
415
|
+
`restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
|
|
416
|
+
`requestedAt: ${receipt.requestedAt}`,
|
|
417
|
+
`finishedAt: ${receipt.finishedAt ?? "-"}`,
|
|
418
|
+
];
|
|
419
|
+
for (const step of receipt.steps) {
|
|
420
|
+
const facts = [
|
|
421
|
+
step.pid === undefined ? undefined : `pid=${step.pid}`,
|
|
422
|
+
step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
|
|
423
|
+
step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
|
|
424
|
+
step.detail,
|
|
425
|
+
].filter((fact) => fact !== undefined);
|
|
426
|
+
lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
|
|
427
|
+
}
|
|
428
|
+
return lines;
|
|
429
|
+
}
|
package/src/services.ts
CHANGED
|
@@ -23,13 +23,20 @@ export interface ResolvePathOptions {
|
|
|
23
23
|
readonly loginPathRunner?: LoginPathRunner;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Service managers this CLI can write definitions for. */
|
|
27
|
+
export type ServicePlatform = "darwin" | "linux";
|
|
28
|
+
|
|
26
29
|
export interface InstallServicesOptions {
|
|
27
30
|
readonly binDir: string;
|
|
28
31
|
readonly launchAgentsDir?: string;
|
|
32
|
+
/** systemd user-unit directory; defaults to ${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user. */
|
|
33
|
+
readonly unitDir?: string;
|
|
34
|
+
/** Service manager to generate for; defaults to the running platform. */
|
|
35
|
+
readonly platform?: ServicePlatform;
|
|
29
36
|
readonly env?: NodeJS.ProcessEnv;
|
|
30
37
|
/** Test seam for the `${SHELL} -lc` invocation used by the default path. */
|
|
31
38
|
readonly loginPathRunner?: LoginPathRunner;
|
|
32
|
-
/** Test seam for counting or inspecting the
|
|
39
|
+
/** Test seam for counting or inspecting the definition writes. */
|
|
33
40
|
readonly writeFile?: PlistWriter;
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -38,25 +45,47 @@ interface ServiceSpec {
|
|
|
38
45
|
readonly label: string;
|
|
39
46
|
readonly binary: string;
|
|
40
47
|
readonly args: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* True for every process that holds a live connection to the gateway. A
|
|
50
|
+
* gateway restart leaves such a process attached to the previous generation:
|
|
51
|
+
* it neither dies nor loses messages, so no health check catches it, and the
|
|
52
|
+
* symptom is replies arriving one beat late (issue #251). The service
|
|
53
|
+
* definition, not an operator convention, has to restart it.
|
|
54
|
+
*/
|
|
55
|
+
readonly dependsOnGateway: boolean;
|
|
41
56
|
}
|
|
42
57
|
|
|
58
|
+
export const GATEWAY_UNIT = "gajaeway-gateway.service";
|
|
59
|
+
|
|
43
60
|
const SERVICE_SPECS: readonly ServiceSpec[] = [
|
|
44
|
-
{
|
|
61
|
+
{
|
|
62
|
+
id: "gateway",
|
|
63
|
+
label: "dev.gajaeway.gateway",
|
|
64
|
+
binary: "gajaeway-gateway",
|
|
65
|
+
args: ["daemon"],
|
|
66
|
+
dependsOnGateway: false,
|
|
67
|
+
},
|
|
45
68
|
{
|
|
46
69
|
id: "adapter-discord",
|
|
47
70
|
label: "dev.gajaeway.adapter-discord",
|
|
48
71
|
binary: "gajaeway-discord",
|
|
49
72
|
args: [],
|
|
73
|
+
dependsOnGateway: true,
|
|
50
74
|
},
|
|
51
75
|
{
|
|
52
76
|
id: "adapter-slack",
|
|
53
77
|
label: "dev.gajaeway.adapter-slack",
|
|
54
78
|
binary: "gajaeway-slack",
|
|
55
79
|
args: [],
|
|
80
|
+
dependsOnGateway: true,
|
|
56
81
|
},
|
|
57
|
-
{ id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"] },
|
|
82
|
+
{ id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"], dependsOnGateway: true },
|
|
58
83
|
];
|
|
59
84
|
|
|
85
|
+
export function serviceSpecs(): readonly ServiceSpec[] {
|
|
86
|
+
return SERVICE_SPECS;
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
function homeForEnvironment(env: NodeJS.ProcessEnv): string {
|
|
61
90
|
return env.GAJAEWAY_HOME || join(env.HOME || homedir(), ".gajaeway");
|
|
62
91
|
}
|
|
@@ -161,6 +190,102 @@ export function renderLaunchAgent(spec: ServiceSpec, binDir: string, home: strin
|
|
|
161
190
|
`;
|
|
162
191
|
}
|
|
163
192
|
|
|
193
|
+
/**
|
|
194
|
+
* systemd reads `Environment=` with its own quoting rules, so a value is wrapped
|
|
195
|
+
* in double quotes with backslashes and quotes escaped. PATH entries containing
|
|
196
|
+
* a space would otherwise silently split into two assignments.
|
|
197
|
+
*/
|
|
198
|
+
function unitEnvironment(name: string, value: string): string {
|
|
199
|
+
return `Environment="${name}=${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function systemdUnitName(spec: ServiceSpec): string {
|
|
203
|
+
return `gajaeway-${spec.id}.service`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function renderSystemdUnit(spec: ServiceSpec, binDir: string, home: string, path: string): string {
|
|
207
|
+
const execStart = [join(binDir, spec.binary), ...spec.args].join(" ");
|
|
208
|
+
const unit = [
|
|
209
|
+
"[Unit]",
|
|
210
|
+
`Description=gajaeway ${spec.id}`,
|
|
211
|
+
// BindsTo restarts and stops this unit with the gateway; PartOf propagates a
|
|
212
|
+
// gateway restart even when this unit is started on its own; After orders
|
|
213
|
+
// the boot so the adapter connects to a listening socket.
|
|
214
|
+
...(spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : []),
|
|
215
|
+
"",
|
|
216
|
+
"[Service]",
|
|
217
|
+
`ExecStart=${execStart}`,
|
|
218
|
+
`WorkingDirectory=${binDir}`,
|
|
219
|
+
unitEnvironment("GAJAEWAY_HOME", home),
|
|
220
|
+
unitEnvironment("PATH", path),
|
|
221
|
+
"Restart=always",
|
|
222
|
+
"RestartSec=2",
|
|
223
|
+
// Only the gateway: GJC daemon and session hosts share the gateway cgroup,
|
|
224
|
+
// so the default control-group kill would take unrelated sessions with it.
|
|
225
|
+
// The gateway exits on its own inside 25s (SHUTDOWN_DEADLINE_MS in the
|
|
226
|
+
// gateway main); the unit's stop window is pinned so that ceiling always fits.
|
|
227
|
+
...(spec.dependsOnGateway ? [] : ["KillMode=process", "TimeoutStopSec=30s"]),
|
|
228
|
+
"",
|
|
229
|
+
"[Install]",
|
|
230
|
+
// Enabling the gateway pulls the whole stack in; a dependent is never
|
|
231
|
+
// wanted by default.target on its own, because it cannot run without one.
|
|
232
|
+
`WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
|
|
233
|
+
"",
|
|
234
|
+
];
|
|
235
|
+
return unit.join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function systemdUnitDir(env: NodeJS.ProcessEnv, userHome: string): string {
|
|
239
|
+
const base = env.XDG_CONFIG_HOME || join(userHome, ".config");
|
|
240
|
+
return join(base, "systemd", "user");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Gets the path where a service's LaunchAgent plist is stored on darwin. */
|
|
244
|
+
export function launchAgentPlistPath(spec: ServiceSpec, launchAgentsDir: string): string {
|
|
245
|
+
return join(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Gets the path where a service's systemd unit file is stored on linux. */
|
|
249
|
+
export function systemdUnitPath(spec: ServiceSpec, unitDir: string): string {
|
|
250
|
+
return join(unitDir, systemdUnitName(spec));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The ordered commands that realign the whole stack.
|
|
255
|
+
*
|
|
256
|
+
* systemd expresses the dependency itself, so one restart of the gateway unit
|
|
257
|
+
* is the entire operation. launchd has no BindsTo/PartOf equivalent and
|
|
258
|
+
* `WatchPaths` does not restart an already-running job, so the ordering is
|
|
259
|
+
* explicit here instead — and `bootout` never appears, because removing a job
|
|
260
|
+
* leaves it with no automatic recovery.
|
|
261
|
+
*/
|
|
262
|
+
export function restartStackCommands(platform: ServicePlatform, uid?: number): readonly (readonly string[])[] {
|
|
263
|
+
if (platform === "linux") return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
|
|
264
|
+
if (platform === "darwin") {
|
|
265
|
+
const target = uid ?? process.getuid?.() ?? 0;
|
|
266
|
+
return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`unsupported service platform: ${platform}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The gateway first, then every job that holds a connection to it. */
|
|
272
|
+
export function restartOrder(): readonly ServiceSpec[] {
|
|
273
|
+
return [
|
|
274
|
+
...SERVICE_SPECS.filter((spec) => !spec.dependsOnGateway),
|
|
275
|
+
...SERVICE_SPECS.filter((spec) => spec.dependsOnGateway),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function requirePlatform(platform: string): ServicePlatform {
|
|
280
|
+
if (platform !== "darwin" && platform !== "linux") throw new Error(`unsupported service platform: ${platform}`);
|
|
281
|
+
return platform;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function currentPlatform(): ServicePlatform {
|
|
285
|
+
if (process.platform === "darwin" || process.platform === "linux") return process.platform;
|
|
286
|
+
throw new Error(`unsupported service platform: ${process.platform}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
164
289
|
async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
|
|
165
290
|
const configPath = join(home, "config.json");
|
|
166
291
|
let raw: string;
|
|
@@ -191,10 +316,14 @@ async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
|
|
|
191
316
|
export async function installServices(options: InstallServicesOptions): Promise<readonly string[]> {
|
|
192
317
|
if (options.binDir.length === 0) throw new Error("services requires a non-empty --bin-dir DIR");
|
|
193
318
|
const env = options.env ?? process.env;
|
|
319
|
+
const platform = options.platform === undefined ? currentPlatform() : requirePlatform(options.platform);
|
|
194
320
|
const userHome = env.HOME || homedir();
|
|
195
321
|
const home = homeForEnvironment(env);
|
|
196
322
|
const binDir = expandHome(options.binDir, userHome);
|
|
197
|
-
const
|
|
323
|
+
const targetDir =
|
|
324
|
+
platform === "darwin"
|
|
325
|
+
? expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome)
|
|
326
|
+
: expandHome(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
|
|
198
327
|
const runtime = await readRuntime(home);
|
|
199
328
|
const path = await resolvePath({
|
|
200
329
|
binDir,
|
|
@@ -203,22 +332,26 @@ export async function installServices(options: InstallServicesOptions): Promise<
|
|
|
203
332
|
env,
|
|
204
333
|
loginPathRunner: options.loginPathRunner,
|
|
205
334
|
});
|
|
206
|
-
await mkdir(
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
335
|
+
await mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
336
|
+
const definitions = SERVICE_SPECS.map((spec) =>
|
|
337
|
+
platform === "darwin"
|
|
338
|
+
? {
|
|
339
|
+
path: join(targetDir, `dev.gajaeway.${spec.id}.plist`),
|
|
340
|
+
contents: renderLaunchAgent(spec, binDir, home, path),
|
|
341
|
+
}
|
|
342
|
+
: { path: join(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) },
|
|
343
|
+
);
|
|
211
344
|
const write = options.writeFile;
|
|
212
|
-
for (const
|
|
213
|
-
if (write) await write(
|
|
345
|
+
for (const definition of definitions) {
|
|
346
|
+
if (write) await write(definition.path, definition.contents);
|
|
214
347
|
else {
|
|
215
|
-
await writeFile(
|
|
216
|
-
await chmod(
|
|
348
|
+
await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 0o600 });
|
|
349
|
+
await chmod(definition.path, 0o600);
|
|
217
350
|
}
|
|
218
351
|
}
|
|
219
|
-
return
|
|
352
|
+
return definitions.map((definition) => definition.path);
|
|
220
353
|
}
|
|
221
354
|
|
|
222
355
|
export function serviceUsage(): string {
|
|
223
|
-
return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR]";
|
|
356
|
+
return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]";
|
|
224
357
|
}
|