systemd-ts 0.3.0 → 0.3.2

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.
@@ -0,0 +1,406 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+
5
+ import { ExecutableInferenceError, InvalidExecDirectiveError, NotifySendError } from "./errors.ts";
6
+ import { Executable } from "./executable.ts";
7
+ import type {
8
+ CommandExecutionOptions,
9
+ CommandOutput,
10
+ NotifyOptions,
11
+ SystemdServiceSection,
12
+ StartStatus,
13
+ SystemdServiceOptions,
14
+ SystemdTimerOptions,
15
+ UnitValue,
16
+ } from "./types.ts";
17
+
18
+ export type Result<TValue, TError> =
19
+ | {
20
+ readonly ok: true;
21
+ readonly value: TValue;
22
+ }
23
+ | {
24
+ readonly ok: false;
25
+ readonly error: TError;
26
+ };
27
+
28
+ type ExecDirectiveKey = (typeof EXEC_DIRECTIVE_KEYS)[number];
29
+
30
+ const EXEC_DIRECTIVE_KEYS = [
31
+ `ExecCondition`,
32
+ `ExecReload`,
33
+ `ExecReloadPost`,
34
+ `ExecStart`,
35
+ `ExecStartPost`,
36
+ `ExecStartPre`,
37
+ `ExecStop`,
38
+ `ExecStopPost`,
39
+ ] as const;
40
+ const execFileAsync = promisify(execFile);
41
+ type SectionLike = object;
42
+
43
+ export function normalizeUnitName(name: string, suffix: `.service` | `.timer`): string {
44
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
45
+ }
46
+
47
+ export function resolveTimerTargetUnit(options: SystemdTimerOptions): string {
48
+ const explicitTarget = options.timer[`Unit`];
49
+ if (typeof explicitTarget === `string` && explicitTarget.length > 0) {
50
+ return explicitTarget;
51
+ }
52
+
53
+ return `${normalizeUnitName(options.name, `.timer`)}.service`;
54
+ }
55
+
56
+ export function defaultUnitDirForScope(scope: `system` | `user`): string {
57
+ if (scope === `user`) {
58
+ return `${process.env[`HOME`] ?? `~`}/.config/systemd/user`;
59
+ }
60
+
61
+ return `/etc/systemd/system`;
62
+ }
63
+
64
+ export function freezeUnitOptions<TOptions extends SystemdServiceOptions | SystemdTimerOptions>(
65
+ options: TOptions,
66
+ ): Readonly<TOptions> {
67
+ return Object.freeze({
68
+ ...options,
69
+ ...(options.unit === undefined ? {} : { unit: cloneUnitSection(options.unit) }),
70
+ ...(options.install === undefined ? {} : { install: cloneUnitSection(options.install) }),
71
+ ...(hasServiceSection(options) ? { service: cloneUnitSection(options.service) } : {}),
72
+ ...(hasTimerSection(options) ? { timer: cloneUnitSection(options.timer) } : {}),
73
+ }) as Readonly<TOptions>;
74
+ }
75
+
76
+ export function cloneUnitSection<TSection extends SectionLike | undefined>(
77
+ section: TSection,
78
+ ): TSection {
79
+ if (section === undefined) {
80
+ return section;
81
+ }
82
+
83
+ const entries = Object.entries(section).map(([key, value]) => {
84
+ if (isUnitValueList(value)) {
85
+ return [key, Object.freeze([...value])] as const;
86
+ }
87
+
88
+ return [key, value] as const;
89
+ });
90
+
91
+ return Object.freeze(Object.fromEntries(entries)) as TSection;
92
+ }
93
+
94
+ export function validateServiceSection(
95
+ service: SystemdServiceSection,
96
+ ): Result<void, InvalidExecDirectiveError> {
97
+ for (const key of EXEC_DIRECTIVE_KEYS) {
98
+ const validation = assertAbsoluteExecValue(key, service[key]);
99
+ if (!validation.ok) {
100
+ return validation;
101
+ }
102
+ }
103
+
104
+ return {
105
+ ok: true,
106
+ value: undefined,
107
+ };
108
+ }
109
+
110
+ export function parseStartStatus(unit: string, output: string): StartStatus {
111
+ const properties = Object.fromEntries(
112
+ output
113
+ .split(`\n`)
114
+ .map((line) => line.trim())
115
+ .filter((line) => line.length > 0)
116
+ .map((line) => {
117
+ const separatorIndex = line.indexOf(`=`);
118
+ if (separatorIndex === -1) {
119
+ return [line, ``] as const;
120
+ }
121
+
122
+ return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)] as const;
123
+ }),
124
+ );
125
+
126
+ return {
127
+ activeState: properties[`ActiveState`] ?? `unknown`,
128
+ execMainStatus:
129
+ properties[`ExecMainStatus`] === undefined || properties[`ExecMainStatus`] === ``
130
+ ? undefined
131
+ : Number(properties[`ExecMainStatus`]),
132
+ result: properties[`Result`] ?? `unknown`,
133
+ subState: properties[`SubState`] ?? `unknown`,
134
+ unit: properties[`Id`] ?? unit,
135
+ };
136
+ }
137
+
138
+ export function renderUnitFile(
139
+ sections: ReadonlyArray<readonly [string, SectionLike | undefined]>,
140
+ ): Result<string, ExecutableInferenceError> {
141
+ try {
142
+ const renderedSections = sections
143
+ .flatMap(([sectionName, section]) => {
144
+ if (section === undefined) {
145
+ return [];
146
+ }
147
+
148
+ const lines: string[] = [];
149
+ for (const [key, value] of Object.entries(section as Record<string, unknown>)) {
150
+ if (value === undefined) {
151
+ continue;
152
+ }
153
+
154
+ if (isUnitValueList(value)) {
155
+ for (const entry of value) {
156
+ const rendered = stringifyUnitValue(entry);
157
+ if (!rendered.ok) {
158
+ throw rendered.error;
159
+ }
160
+ lines.push(`${key}=${rendered.value}`);
161
+ }
162
+ continue;
163
+ }
164
+
165
+ const rendered = stringifyUnitValue(value as UnitValue);
166
+ if (!rendered.ok) {
167
+ throw rendered.error;
168
+ }
169
+ lines.push(`${key}=${rendered.value}`);
170
+ }
171
+
172
+ if (lines.length === 0) {
173
+ return [];
174
+ }
175
+
176
+ return [`[${sectionName}]`, ...lines, ``];
177
+ })
178
+ .join(`\n`);
179
+
180
+ return {
181
+ ok: true,
182
+ value: renderedSections.endsWith(`\n`) ? renderedSections : `${renderedSections}\n`,
183
+ };
184
+ } catch (error) {
185
+ return {
186
+ ok: false,
187
+ error:
188
+ error instanceof ExecutableInferenceError
189
+ ? error
190
+ : new ExecutableInferenceError({ cause: error }),
191
+ };
192
+ }
193
+ }
194
+
195
+ export async function defaultCommandExecutor(
196
+ command: string,
197
+ args: readonly string[],
198
+ options: CommandExecutionOptions = {},
199
+ ): Promise<CommandOutput> {
200
+ const result = await execFileAsync(
201
+ command,
202
+ [...args],
203
+ options.env === undefined ? {} : { env: { ...process.env, ...options.env } },
204
+ );
205
+ return {
206
+ stderr: result.stderr,
207
+ stdout: result.stdout,
208
+ };
209
+ }
210
+
211
+ export async function fileExists(path: string): Promise<boolean> {
212
+ try {
213
+ await access(path);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ export async function sendNotify(
221
+ state: `READY=1` | `WATCHDOG=1`,
222
+ options: NotifyOptions,
223
+ ): Promise<void> {
224
+ const args: string[] = [`--no-block`, state];
225
+ if (options.pid !== undefined) {
226
+ args.push(`MAINPID=${options.pid}`);
227
+ }
228
+ if (options.status !== undefined) {
229
+ args.push(`STATUS=${options.status}`);
230
+ }
231
+
232
+ if (options.executor !== undefined) {
233
+ try {
234
+ await options.executor(
235
+ `systemd-notify`,
236
+ args,
237
+ options.socketPath === undefined ? {} : { env: { NOTIFY_SOCKET: options.socketPath } },
238
+ );
239
+ } catch (cause) {
240
+ throw new NotifySendError(
241
+ `Failed to send systemd notification through the configured executor`,
242
+ {
243
+ args,
244
+ cause,
245
+ command: `systemd-notify`,
246
+ reason: `executor-failed`,
247
+ stage: `executor`,
248
+ },
249
+ );
250
+ }
251
+ return;
252
+ }
253
+
254
+ try {
255
+ const result = await execFileAsync(`systemd-notify`, args, {
256
+ env: {
257
+ ...process.env,
258
+ ...(options.socketPath === undefined ? {} : { NOTIFY_SOCKET: options.socketPath }),
259
+ },
260
+ });
261
+
262
+ void result;
263
+ } catch (cause) {
264
+ throw new NotifySendError(`Failed to send systemd notification with systemd-notify`, {
265
+ args,
266
+ cause,
267
+ command: `systemd-notify`,
268
+ reason: `systemd-notify-failed`,
269
+ stage: `systemd-notify`,
270
+ });
271
+ }
272
+ }
273
+
274
+ export function extractCommandOutput(cause: unknown): CommandOutput | undefined {
275
+ if (cause === null || typeof cause !== `object`) {
276
+ return undefined;
277
+ }
278
+
279
+ const record = cause as Record<string, unknown>;
280
+ const stdout = typeof record[`stdout`] === `string` ? record[`stdout`] : undefined;
281
+ const stderr = typeof record[`stderr`] === `string` ? record[`stderr`] : undefined;
282
+ if (stdout === undefined && stderr === undefined) {
283
+ return undefined;
284
+ }
285
+
286
+ return {
287
+ stderr: stderr ?? ``,
288
+ stdout: stdout ?? ``,
289
+ };
290
+ }
291
+
292
+ function hasServiceSection(
293
+ options: SystemdServiceOptions | SystemdTimerOptions,
294
+ ): options is SystemdServiceOptions {
295
+ return `service` in options;
296
+ }
297
+
298
+ function hasTimerSection(
299
+ options: SystemdServiceOptions | SystemdTimerOptions,
300
+ ): options is SystemdTimerOptions {
301
+ return `timer` in options;
302
+ }
303
+
304
+ function assertAbsoluteExecValue(
305
+ key: ExecDirectiveKey,
306
+ value: unknown,
307
+ ): Result<void, InvalidExecDirectiveError> {
308
+ if (isUnitValueList(value)) {
309
+ for (const entry of value) {
310
+ const validation = assertAbsoluteExecEntry(key, entry);
311
+ if (!validation.ok) {
312
+ return validation;
313
+ }
314
+ }
315
+
316
+ return {
317
+ ok: true,
318
+ value: undefined,
319
+ };
320
+ }
321
+
322
+ return assertAbsoluteExecEntry(key, value as UnitValue | undefined);
323
+ }
324
+
325
+ function assertAbsoluteExecEntry(
326
+ key: ExecDirectiveKey,
327
+ value: UnitValue | undefined,
328
+ ): Result<void, InvalidExecDirectiveError> {
329
+ if (typeof value === `string` && value.length > 0 && !isAbsoluteExecCommand(value)) {
330
+ return {
331
+ ok: false,
332
+ error: new InvalidExecDirectiveError(key),
333
+ };
334
+ }
335
+
336
+ if (value instanceof Executable && !value.runtimeEntrypoint.startsWith(`/`)) {
337
+ return {
338
+ ok: false,
339
+ error: new InvalidExecDirectiveError(key),
340
+ };
341
+ }
342
+
343
+ return {
344
+ ok: true,
345
+ value: undefined,
346
+ };
347
+ }
348
+
349
+ function stringifyUnitValue(value: UnitValue): Result<string, ExecutableInferenceError> {
350
+ if (value instanceof Executable) {
351
+ return value.toExecStart();
352
+ }
353
+
354
+ if (typeof value === `boolean`) {
355
+ return {
356
+ ok: true,
357
+ value: value ? `true` : `false`,
358
+ };
359
+ }
360
+
361
+ return {
362
+ ok: true,
363
+ value: String(value),
364
+ };
365
+ }
366
+
367
+ function isUnitValueList(value: unknown): value is readonly UnitValue[] {
368
+ return Array.isArray(value);
369
+ }
370
+
371
+ function isAbsoluteExecCommand(value: string): boolean {
372
+ let index = 0;
373
+ let hasPrivilegePrefix = false;
374
+
375
+ while (index < value.length) {
376
+ const prefix = value[index];
377
+ if (prefix === `@` || prefix === `-` || prefix === `:`) {
378
+ index += 1;
379
+ continue;
380
+ }
381
+
382
+ if (prefix === `+`) {
383
+ if (hasPrivilegePrefix) {
384
+ return false;
385
+ }
386
+
387
+ hasPrivilegePrefix = true;
388
+ index += 1;
389
+ continue;
390
+ }
391
+
392
+ if (prefix === `!`) {
393
+ if (hasPrivilegePrefix) {
394
+ return false;
395
+ }
396
+
397
+ hasPrivilegePrefix = true;
398
+ index += value[index + 1] === `!` ? 2 : 1;
399
+ continue;
400
+ }
401
+
402
+ break;
403
+ }
404
+
405
+ return value[index] === `/`;
406
+ }
@@ -0,0 +1,67 @@
1
+ import { NotifySendError } from "./errors.ts";
2
+ import { sendNotify, type Result } from "./internal.ts";
3
+ import type { NotifyOptions } from "./types.ts";
4
+
5
+ /**
6
+ * Helpers for sending `sd_notify` state updates to systemd.
7
+ *
8
+ * These helpers send notification payloads to the socket identified by
9
+ * `NOTIFY_SOCKET`, or to `options.socketPath` when one is provided explicitly.
10
+ * They are useful both in real services and in tests that want to observe
11
+ * readiness or watchdog traffic directly.
12
+ *
13
+ * Source:
14
+ * - https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
15
+ * - https://www.freedesktop.org/software/systemd/man/latest/systemd-notify.html
16
+ */
17
+ export const notify = {
18
+ /**
19
+ * Sends `READY=1` to systemd.
20
+ *
21
+ * Use this when a `Type=notify` service has completed its startup work and is
22
+ * ready to be considered fully started. If `options.status` is provided, it is
23
+ * sent as an additional `STATUS=...` field.
24
+ */
25
+ async ready(options: NotifyOptions = {}): Promise<Result<void, NotifySendError>> {
26
+ try {
27
+ await sendNotify(`READY=1`, options);
28
+ return { ok: true, value: undefined };
29
+ } catch (error) {
30
+ return {
31
+ ok: false,
32
+ error:
33
+ error instanceof NotifySendError
34
+ ? error
35
+ : new NotifySendError(`Failed to send READY=1 notification`, {
36
+ cause: error,
37
+ stage: `ready`,
38
+ }),
39
+ };
40
+ }
41
+ },
42
+
43
+ /**
44
+ * Sends `WATCHDOG=1` to systemd.
45
+ *
46
+ * Use this when a service configured with `WatchdogSec=` needs to emit a
47
+ * watchdog heartbeat. If `options.pid` is provided, it is sent as
48
+ * `MAINPID=...`, and `options.status` is forwarded as `STATUS=...`.
49
+ */
50
+ async watchdog(options: NotifyOptions = {}): Promise<Result<void, NotifySendError>> {
51
+ try {
52
+ await sendNotify(`WATCHDOG=1`, options);
53
+ return { ok: true, value: undefined };
54
+ } catch (error) {
55
+ return {
56
+ ok: false,
57
+ error:
58
+ error instanceof NotifySendError
59
+ ? error
60
+ : new NotifySendError(`Failed to send WATCHDOG=1 notification`, {
61
+ cause: error,
62
+ stage: `watchdog`,
63
+ }),
64
+ };
65
+ }
66
+ },
67
+ };