@push.rocks/smartdaemon 2.17.0 → 2.19.0

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.
@@ -2,6 +2,7 @@ import * as plugins from './smartdaemon.plugins.js';
2
2
  import { environmentByteLimit, environmentVariableLimit, isCleanText, renderServiceRelations, systemdAddressFamilies,
3
3
  systemdCapabilities, type TSystemdServiceStartup, type ISystemdServiceDependencies, type ISystemdServiceInstallTargets,
4
4
  type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily } from './systemd.definition.js';
5
+ import { fileDescriptorStorePreserveMajor } from './systemd.managerversion.js';
5
6
  export type { ISystemdServiceStartup, ISystemdServiceNotifyStartup, TSystemdServiceStartup,
6
7
  ISystemdServiceDependencies, ISystemdServiceInstallTargets, ISystemdServiceCredential, TSystemdCapability,
7
8
  TSystemdAddressFamily } from './systemd.definition.js';
@@ -65,7 +66,9 @@ interface ISystemdServiceDefinitionBaseOptions {
65
66
  * When the manager releases the store (`FileDescriptorStorePreserve=`, systemd 254 or later):
66
67
  * `'no'` when the service stops, `'restart'` (systemd's default) once it is neither active nor
67
68
  * about to restart, `'yes'` only when the unit leaves the manager's memory or is cleaned with
68
- * `SystemdUnit.cleanFileDescriptorStore()`. Requires `fileDescriptorStoreMax`.
69
+ * `SystemdUnit.cleanFileDescriptorStore()`. Requires `fileDescriptorStoreMax`. Sets
70
+ * `requiredManagerMajor` to 254, so `SystemdUnitFile.install()` refuses an older manager, which
71
+ * would ignore the line.
69
72
  */
70
73
  fileDescriptorStorePreserve?: 'no' | 'yes' | 'restart';
71
74
  /**
@@ -172,6 +175,12 @@ export class SystemdServiceDefinition {
172
175
  public readonly scope: 'system' | 'user';
173
176
  public readonly content: string;
174
177
  public readonly sha256: string;
178
+ /**
179
+ * The oldest systemd major whose manager reads every directive this definition renders, when it
180
+ * renders one an older manager would ignore: 254 with `fileDescriptorStorePreserve`; otherwise
181
+ * null. `SystemdUnitFile.install()` refuses a definition on an older manager.
182
+ */
183
+ public readonly requiredManagerMajor: number | null;
175
184
 
176
185
  constructor(options: ISystemdServiceDefinitionOptions) {
177
186
  const fail = (option: TSystemdServiceDefinitionOption): never => {
@@ -508,6 +517,8 @@ export class SystemdServiceDefinition {
508
517
  ].join('\n');
509
518
  requireValue(Buffer.byteLength(this.content) <= 16384, 'options');
510
519
  this.sha256 = plugins.crypto.createHash('sha256').update(this.content).digest('hex');
520
+ this.requiredManagerMajor = options.fileDescriptorStorePreserve !== undefined
521
+ ? fileDescriptorStorePreserveMajor : null;
511
522
  Object.freeze(this);
512
523
  }
513
524
  }
@@ -1,6 +1,12 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
2
  import { isCleanText } from './systemd.definition.js';
3
3
  import { captureUserEnvironment, inspectRuntimeDirectory, isSafeDirectory } from './systemd.userenvironment.js';
4
+ import {
5
+ type ISystemdManagerVersion,
6
+ managerVersionArguments,
7
+ parseManagerVersion,
8
+ transientScopeMajor,
9
+ } from './systemd.managerversion.js';
4
10
 
5
11
  export interface ISystemdTransientScopeOptions {
6
12
  /** The calling user's manager; the only manager a transient scope is started in. */
@@ -51,8 +57,16 @@ export type TSystemdTransientScopeErrorCode =
51
57
  | 'invalid_options'
52
58
  /** The process is not running on Linux. */
53
59
  | 'unsupported_platform'
54
- /** No runtime directory is set or it does not exist, or the manager does not answer systemctl. */
60
+ /**
61
+ * No runtime directory is set or it does not exist, or the manager does not answer systemctl
62
+ * with a version it reports as systemd does (`SystemdUnit.inspectManagerVersion()`).
63
+ */
55
64
  | 'user_manager_unavailable'
65
+ /**
66
+ * The user manager is older than systemd 254, whose systemd-run this call needs; always a
67
+ * `SystemdTransientScopeManagerTooOldError`. Nothing was started.
68
+ */
69
+ | 'manager_too_old'
56
70
  /** systemd-run exited without reporting the scope running; the program has not run. */
57
71
  | 'scope_start_failed'
58
72
  /** The manager probe or the program did not finish, and close its output, in time. */
@@ -74,6 +88,24 @@ export class SystemdTransientScopeError extends Error {
74
88
  }
75
89
  }
76
90
 
91
+ /** Every `manager_too_old` refusal: names the user manager's version and the one required. */
92
+ export class SystemdTransientScopeManagerTooOldError extends SystemdTransientScopeError {
93
+ declare public readonly code: 'manager_too_old';
94
+
95
+ constructor(
96
+ unitName: string,
97
+ /** The user manager's version the probe read. */
98
+ public readonly managerVersion: ISystemdManagerVersion,
99
+ /** The oldest systemd major the call needs. */
100
+ public readonly requiredMajor: number,
101
+ ) {
102
+ super('manager_too_old', unitName, '');
103
+ this.message = `Systemd transient scope failed: manager_too_old (${unitName}: systemd ` +
104
+ `${managerVersion.major} running, ${requiredMajor} required).`;
105
+ this.name = 'SystemdTransientScopeManagerTooOldError';
106
+ }
107
+ }
108
+
77
109
  const outputLimit = 1048576;
78
110
  const reservedName = (name: string) =>
79
111
  name === 'XDG_RUNTIME_DIR' || name === 'INVOCATION_ID' || name.startsWith('SYSTEMD_') || name.startsWith('DBUS_');
@@ -227,7 +259,9 @@ const programStderr = (stderr: string, unitName: string): string | undefined =>
227
259
  * must not keep the program's stdout or stderr open, or the call runs into its timeout.
228
260
  *
229
261
  * On `timed_out` and `output_limit` the direct process is killed; processes it started stay in
230
- * the scope. A program that exits with a failure status is a result, not an error.
262
+ * the scope. A program that exits with a failure status is a result, not an error. A user manager
263
+ * older than systemd 254 is refused with `SystemdTransientScopeManagerTooOldError` before
264
+ * systemd-run is started.
231
265
  */
232
266
  export const runInTransientScope = async (
233
267
  options: ISystemdTransientScopeOptions,
@@ -247,13 +281,19 @@ export const runInTransientScope = async (
247
281
  if (admission !== 'private') fail('invalid_options');
248
282
 
249
283
  const probe = await runProcess(validated.systemctlPath,
250
- ['--user', '--no-ask-password', '--no-pager', 'show', '--property=Version'],
284
+ ['--user', '--no-ask-password', '--no-pager', ...managerVersionArguments],
251
285
  { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', XDG_RUNTIME_DIR: runtimeDirectory! }, validated.timeoutMs);
252
286
  if (probe.kind === 'timed_out') fail('timed_out', probe.stderr);
253
- if (probe.kind !== 'exited' || probe.code !== 0 || !probe.stdout.startsWith('Version=')) {
254
- fail('user_manager_unavailable', probe.kind === 'spawn_failed' ? '' : probe.stderr,
287
+ const managerVersion = probe.kind === 'exited' && probe.code === 0 ? parseManagerVersion(probe.stdout) : undefined;
288
+ if (managerVersion === undefined) {
289
+ return fail('user_manager_unavailable', probe.kind === 'spawn_failed' ? '' : probe.stderr,
255
290
  probe.kind === 'spawn_failed' ? probe.error : undefined);
256
291
  }
292
+ // systemd-run before 254 knows no --expand-environment; the running user manager's version is the
293
+ // one this call can read before starting anything, so it stands for the installation's.
294
+ if (managerVersion.major < transientScopeMajor) {
295
+ throw new SystemdTransientScopeManagerTooOldError(unitName, managerVersion, transientScopeMajor);
296
+ }
257
297
 
258
298
  // systemd-run registers its own process as the scope, then executes the program in place with
259
299
  // its environment, so that environment is the program's own plus INVOCATION_ID.
@@ -8,6 +8,14 @@ import {
8
8
  isUserManagerEnvironment,
9
9
  } from './systemd.userenvironment.js';
10
10
  import { environmentByteLimit, environmentVariableLimit, isCleanText } from './systemd.definition.js';
11
+ import {
12
+ type ISystemdManagerVersion,
13
+ fileDescriptorStoreCleanMajor,
14
+ fileDescriptorStorePreserveMajor,
15
+ managerVersionArguments,
16
+ parseManagerVersion,
17
+ } from './systemd.managerversion.js';
18
+ export type { ISystemdManagerVersion } from './systemd.managerversion.js';
11
19
 
12
20
  export interface ISystemdUnitOptions {
13
21
  unitName: string;
@@ -167,12 +175,45 @@ export interface ISystemdServiceSettings {
167
175
  }
168
176
 
169
177
  export class SystemdUnitError extends Error {
170
- constructor(public readonly code: 'invalid_options' | 'command_failed' | 'invalid_state') {
171
- super(`Systemd unit operation failed: ${code}.`);
178
+ constructor(
179
+ /**
180
+ * `released_uninspected`: only `cleanFileDescriptorStore()`. The manager released the store, and
181
+ * the snapshot read afterwards failed; `cause` is that inspection's error.
182
+ * `version_unknown`: the manager answered, but its `Version` property is not one this package
183
+ * reads, so a call that needs a minimum manager version refuses.
184
+ * `manager_too_old`: always a `SystemdUnitManagerTooOldError`, naming both versions.
185
+ */
186
+ public readonly code:
187
+ | 'invalid_options'
188
+ | 'command_failed'
189
+ | 'invalid_state'
190
+ | 'released_uninspected'
191
+ | 'version_unknown'
192
+ | 'manager_too_old',
193
+ options?: ErrorOptions,
194
+ ) {
195
+ super(`Systemd unit operation failed: ${code}.`, options);
172
196
  this.name = 'SystemdUnitError';
173
197
  }
174
198
  }
175
199
 
200
+ /** Every `manager_too_old` refusal: the running manager is older than the call needs. */
201
+ export class SystemdUnitManagerTooOldError extends SystemdUnitError {
202
+ declare public readonly code: 'manager_too_old';
203
+
204
+ constructor(
205
+ /** The running manager's version the refusal read. */
206
+ public readonly managerVersion: ISystemdManagerVersion,
207
+ /** The oldest systemd major the refused call needs. */
208
+ public readonly requiredMajor: number,
209
+ ) {
210
+ super('manager_too_old');
211
+ this.message = `Systemd unit operation failed: manager_too_old (systemd ${managerVersion.major} ` +
212
+ `running, ${requiredMajor} required).`;
213
+ this.name = 'SystemdUnitManagerTooOldError';
214
+ }
215
+ }
216
+
176
217
  // systemd's format_timespan() units, largest first; a year and a month are 365.25 and 30.44 days.
177
218
  const timespanUnits = new Map<string, bigint>([
178
219
  ['y', 31557600000000n], ['month', 2629800000000n], ['w', 604800000000n], ['d', 86400000000n],
@@ -507,11 +548,42 @@ export class SystemdUnit {
507
548
  });
508
549
  }
509
550
 
551
+ /**
552
+ * Reads the running manager's version (`systemctl show --property=Version`) through the unit's
553
+ * scope: the system manager, or the calling user's manager for user scope. A manager that does
554
+ * not answer rejects with `command_failed`; an answer this package cannot read, with
555
+ * `version_unknown`.
556
+ */
557
+ public async inspectManagerVersion(): Promise<ISystemdManagerVersion> {
558
+ const version = parseManagerVersion(await this.command([...managerVersionArguments]));
559
+ if (version === undefined) throw new SystemdUnitError('version_unknown');
560
+ return version;
561
+ }
562
+
563
+ /**
564
+ * Resolves with the running manager's version when its major is at least `requiredMajor`, an
565
+ * integer from 1 to 9999; otherwise rejects with a `SystemdUnitManagerTooOldError`. Fails closed:
566
+ * an unreadable version rejects with `version_unknown`, an unanswered read with
567
+ * `command_failed`, and a malformed `requiredMajor` with `invalid_options` before any command.
568
+ */
569
+ public async requireManagerVersion(requiredMajor: number): Promise<ISystemdManagerVersion> {
570
+ if (!Number.isSafeInteger(requiredMajor) || requiredMajor < 1 || requiredMajor > 9999) {
571
+ throw new SystemdUnitError('invalid_options');
572
+ }
573
+ const version = await this.inspectManagerVersion();
574
+ if (version.major < requiredMajor) throw new SystemdUnitManagerTooOldError(version, requiredMajor);
575
+ return version;
576
+ }
577
+
510
578
  /**
511
579
  * Reports the loaded service settings that a definition renders, so an owner can verify an
512
580
  * installed unit carries them. Reads only; the manager must have reloaded changed files first.
581
+ * Needs systemd 254 or later, which first reports `FileDescriptorStorePreserve`: an older manager
582
+ * is refused with `SystemdUnitManagerTooOldError` before the unit is read, and so is a manager
583
+ * whose version is unreadable, with `version_unknown`.
513
584
  */
514
585
  public async inspectServiceSettings(): Promise<ISystemdServiceSettings> {
586
+ await this.requireManagerVersion(fileDescriptorStorePreserveMajor);
515
587
  const keys = ['Id', 'Type', 'NotifyAccess', 'Restart', 'RestartUSec', 'KillMode', 'TimeoutStopUSec',
516
588
  'TimeoutStartUSec', 'RestartPreventExitStatus', 'SuccessExitStatus', 'StartLimitIntervalUSec', 'StartLimitBurst',
517
589
  'Delegate', 'LimitNOFILE', 'LimitNOFILESoft', 'TasksMax', 'OOMScoreAdjust', 'FileDescriptorStoreMax',
@@ -523,7 +595,7 @@ export class SystemdUnit {
523
595
  if (separator < 1 || !keys.includes(key) || fields.has(key)) throw new SystemdUnitError('invalid_state');
524
596
  fields.set(key, line.slice(separator + 1));
525
597
  }
526
- // An older manager omits a property it does not know; the count check refuses that snapshot.
598
+ // A manager that omits a requested property it should know fails this count check.
527
599
  if (fields.size !== keys.length || fields.get('Id') !== this.unitName) throw new SystemdUnitError('invalid_state');
528
600
  const mode = (key: string, empty = false): string => {
529
601
  const value = fields.get(key)!;
@@ -581,10 +653,27 @@ export class SystemdUnit {
581
653
  * `command_failed`, unless the unit is loaded, inactive (not failed) and without a pending job,
582
654
  * and its loaded `FileDescriptorStoreMax` is above zero or the store still holds a descriptor.
583
655
  * Nothing else of the unit is cleaned.
656
+ *
657
+ * Every outcome after the manager accepted the clean states that the store was released: the
658
+ * call resolves with the snapshot read afterwards, which reports the unit as it is then (a start
659
+ * after the release may already have stored descriptors again), or rejects with
660
+ * `released_uninspected` and that inspection's error as `cause`. A clean the manager refused
661
+ * or that could not be run rejects with `command_failed`; so does one whose `systemctl` call
662
+ * exceeded `commandTimeoutMs`, whose outcome is then unknown, so after `command_failed` a caller
663
+ * must `inspect()` the unit before treating the store as kept.
664
+ *
665
+ * Needs systemd 254 or later, the first that cleans a store: the manager's version is read first,
666
+ * and an older manager is refused with `SystemdUnitManagerTooOldError`, an unreadable version with
667
+ * `version_unknown` and an unanswered read with `command_failed`, each before any clean is run.
584
668
  */
585
669
  public async cleanFileDescriptorStore(): Promise<ISystemdUnitState> {
670
+ await this.requireManagerVersion(fileDescriptorStoreCleanMajor);
586
671
  await this.command(['clean', '--what=fdstore', this.unitName]);
587
- return this.inspect();
672
+ try {
673
+ return await this.inspect();
674
+ } catch (error) {
675
+ throw new SystemdUnitError('released_uninspected', { cause: error });
676
+ }
588
677
  }
589
678
 
590
679
  /** Reloads unit definitions globally; does not enable, unmask, start or stop a unit. */
@@ -270,6 +270,11 @@ export class SystemdUnitFile {
270
270
  /**
271
271
  * Expected previous digest is mandatory; null admits an absent file. Exact desired bytes are idempotent.
272
272
  * Retains changed definitions beside the unit before replacement. A failed reload never rolls code back.
273
+ *
274
+ * A definition with a `requiredManagerMajor` is admitted only on a manager at least that new,
275
+ * read before any directory, file or other systemctl effect: an older manager rejects with
276
+ * the unit's `SystemdUnitManagerTooOldError` (`manager_too_old`), an unreadable version with
277
+ * `SystemdUnitError` `version_unknown` and a failed read with `SystemdUnitError` `command_failed`.
273
278
  */
274
279
  public async install(
275
280
  definition: SystemdServiceDefinition,
@@ -288,6 +293,10 @@ export class SystemdUnitFile {
288
293
  // instead of a wrapped write_failed.
289
294
  const maskDirectories = this.maskDirectories();
290
295
  if (maskDirectories === null) throw new SystemdUnitFileError('invalid_options');
296
+ // A manager older than the definition needs would load the unit without the newer directives.
297
+ if (definition.requiredManagerMajor !== null) {
298
+ await this.unit.requireManagerVersion(definition.requiredManagerMajor);
299
+ }
291
300
  requireValue(!this.busy, 'busy');
292
301
  this.busy = true;
293
302
  let temporary: string | undefined;
@@ -0,0 +1,38 @@
1
+ /** The version of a running systemd manager, as the manager itself reports it. */
2
+ export interface ISystemdManagerVersion {
3
+ /** systemd's major version: the leading number of `version`, `255` for `255.4-1ubuntu8.17`. */
4
+ readonly major: number;
5
+ /**
6
+ * The manager's `Version` property verbatim, as its build names it: `255.4-1ubuntu8.17`,
7
+ * `253.5-1`, `256~rc3` or `257.1-1.fc41`. It is the running manager's version, which differs
8
+ * from `systemctl --version` after an upgrade until the manager re-executes.
9
+ */
10
+ readonly version: string;
11
+ }
12
+
13
+ /** Internal: the systemd major that first reads `FileDescriptorStorePreserve=` and reports it. */
14
+ export const fileDescriptorStorePreserveMajor = 254;
15
+
16
+ /** Internal: the systemd major whose `systemctl clean` first releases a file descriptor store (`--what=fdstore`). */
17
+ export const fileDescriptorStoreCleanMajor = 254;
18
+
19
+ /**
20
+ * Internal: the systemd major whose `systemd-run` first takes `--expand-environment` and reports a
21
+ * started scope as `runInTransientScope()` reads it.
22
+ */
23
+ export const transientScopeMajor = 254;
24
+
25
+ /** Internal: the `systemctl` verb and argument that print the manager's `Version` property. */
26
+ export const managerVersionArguments: readonly string[] = ['show', '--property=Version'];
27
+
28
+ /**
29
+ * Internal: parses the output of `systemctl show --property=Version`, exactly one
30
+ * `Version=<version>` line. The version is a major of 1–4 digits without a leading zero,
31
+ * optionally followed by a separator (`.`, `~`, `-`, `+`, `^` or `_`) and printable ASCII, at
32
+ * most 256 characters in all. Anything else, `systemctl --version`'s `systemd 255 (255.4-1)`
33
+ * format included, is refused with undefined.
34
+ */
35
+ export const parseManagerVersion = (stdout: string): ISystemdManagerVersion | undefined => {
36
+ const match = /^Version=(([1-9][0-9]{0,3})(?:[.~+^_-][\x21-\x7e]{0,251})?)\n$/.exec(stdout);
37
+ return match ? Object.freeze({ major: Number(match[2]), version: match[1] }) : undefined;
38
+ };