dsh-autorestart 0.7.0 → 0.8.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * REGISTERING THE WATCHDOG — one way per tier.
3
+ *
4
+ * The tiers differ in ONE thing only: how the watchdog is made to run later.
5
+ * Everything after that (wait, re-check, restore, start again, report) is the
6
+ * same code for every tier — see lib/watchdog.js.
7
+ *
8
+ * Each registration says what it did:
9
+ *
10
+ * - the Windows task is read back after it was registered (and deleted
11
+ * completely afterwards);
12
+ * - the systemd unit is reset (`reset-failed`) when it is over, so it does
13
+ * not lie around as a failed unit;
14
+ * - the detached process writes its pid into the state directory, and the
15
+ * process is READ BACK to prove that it is really running.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ import { defaultExec, TIERS } from './watchdog-tier.js';
22
+
23
+ /**
24
+ * The XML of a one-off Windows task.
25
+ *
26
+ * Two things are easy to get wrong, and both are learned the hard way:
27
+ *
28
+ * - a one-off task needs an `EndBoundary`: without it the task has no end
29
+ * and the registration is refused;
30
+ * - `MultipleInstancesPolicy` must be `IgnoreNew`: a second instance of the
31
+ * watchdog would fight the first one over the same lock.
32
+ *
33
+ * @param {{ startIso: string, endIso: string, command: string, description?: string }} options
34
+ * @returns {string}
35
+ */
36
+ export function buildScheduledTaskXml({ startIso, endIso, command, description = 'dsh-autorestart watchdog' }) {
37
+ const escape = (value) => String(value ?? '')
38
+ .replace(/&/g, '&')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;');
42
+ return [
43
+ '<?xml version="1.0" encoding="UTF-16"?>',
44
+ '<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
45
+ ' <RegistrationInfo>',
46
+ ' <Description>' + escape(description) + '</Description>',
47
+ ' </RegistrationInfo>',
48
+ ' <Triggers>',
49
+ ' <TimeTrigger>',
50
+ ' <StartBoundary>' + escape(startIso) + '</StartBoundary>',
51
+ ' <EndBoundary>' + escape(endIso) + '</EndBoundary>',
52
+ ' <Enabled>true</Enabled>',
53
+ ' </TimeTrigger>',
54
+ ' </Triggers>',
55
+ ' <Principals>',
56
+ ' <Principal id="Author">',
57
+ ' <LogonType>InteractiveToken</LogonType>',
58
+ ' <RunLevel>LeastPrivilege</RunLevel>',
59
+ ' </Principal>',
60
+ ' </Principals>',
61
+ ' <Settings>',
62
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
63
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
64
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
65
+ ' <Enabled>true</Enabled>',
66
+ ' <ExecutionTimeLimit>PT1H</ExecutionTimeLimit>',
67
+ ' </Settings>',
68
+ ' <Actions Context="Author">',
69
+ ' <Exec>',
70
+ ' <Command>cmd</Command>',
71
+ ' <Arguments>/c ' + escape(command) + '</Arguments>',
72
+ ' </Exec>',
73
+ ' </Actions>',
74
+ '</Task>',
75
+ '',
76
+ ].join('\r\n');
77
+ }
78
+
79
+ /**
80
+ * Register the one-off Windows task and read it back.
81
+ *
82
+ * @param {{
83
+ * taskName: string,
84
+ * startIso: string,
85
+ * endIso: string,
86
+ * command: string,
87
+ * xmlPath: string,
88
+ * exec?: Function,
89
+ * writeFile?: Function,
90
+ * removeFile?: Function,
91
+ * }} options
92
+ * @returns {{ ok: boolean, taskName: string, error: string|null, steps: Array<object> }}
93
+ */
94
+ export function registerScheduledTask({
95
+ taskName,
96
+ startIso,
97
+ endIso,
98
+ command,
99
+ xmlPath,
100
+ exec = defaultExec,
101
+ writeFile = (target, content) => fs.writeFileSync(target, content, 'utf8'),
102
+ removeFile = (target) => fs.rmSync(target, { force: true }),
103
+ }) {
104
+ const steps = [];
105
+ const step = (kind, ok, detail) => {
106
+ steps.push({ kind, ok, detail: detail === undefined ? null : detail });
107
+ return ok;
108
+ };
109
+ const xml = buildScheduledTaskXml({ startIso, endIso, command });
110
+ try {
111
+ writeFile(xmlPath, xml);
112
+ } catch (error) {
113
+ step('write-xml', false, error && error.message ? error.message : String(error));
114
+ return { ok: false, taskName, error: 'the task description could not be written', steps };
115
+ }
116
+ step('write-xml', true, xmlPath);
117
+ const created = exec('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F']);
118
+ if (!step('create', created.ok, created.ok ? 'the task was registered' : created.error)) {
119
+ removeFile(xmlPath);
120
+ return { ok: false, taskName, error: created.error || 'the task was refused', steps };
121
+ }
122
+ // The read back: "it answered" is not "it is there".
123
+ const query = exec('schtasks', ['/Query', '/TN', taskName, '/XML']);
124
+ if (!step('verify', query.ok, query.ok ? 'the task was read back' : query.error)) {
125
+ removeFile(xmlPath);
126
+ return { ok: false, taskName, error: query.error || 'the task cannot be read back', steps };
127
+ }
128
+ const start = /<StartBoundary>([^<]+)<\/StartBoundary>/i.exec(query.stdout || '');
129
+ const end = /<EndBoundary>([^<]+)<\/EndBoundary>/i.exec(query.stdout || '');
130
+ const triggerOk = start !== null && String(start[1]).slice(0, 16) === String(startIso).slice(0, 16);
131
+ if (!step('trigger', triggerOk, 'expected ' + startIso + ', got ' + (start ? start[1] : 'nothing'))) {
132
+ removeFile(xmlPath);
133
+ return { ok: false, taskName, error: 'the task was registered with another trigger', steps };
134
+ }
135
+ if (!step('end-boundary', end !== null, 'a one-off task needs an EndBoundary')) {
136
+ removeFile(xmlPath);
137
+ return { ok: false, taskName, error: 'the task has no end boundary', steps };
138
+ }
139
+ const ignoreNew = /<MultipleInstancesPolicy>IgnoreNew<\/MultipleInstancesPolicy>/i.test(query.stdout || '');
140
+ step('ignore-new', ignoreNew, 'a second instance must not fight the first one');
141
+ removeFile(xmlPath);
142
+ return { ok: true, taskName, error: null, steps };
143
+ }
144
+
145
+ /**
146
+ * Delete the one-off Windows task completely.
147
+ *
148
+ * @param {{ taskName: string, exec?: Function }} options
149
+ * @returns {{ ok: boolean, error: string|null }}
150
+ */
151
+ export function removeScheduledTask({ taskName, exec = defaultExec }) {
152
+ const answer = exec('schtasks', ['/Delete', '/TN', taskName, '/F']);
153
+ return { ok: answer.ok === true, error: answer.ok ? null : (answer.error || 'the task could not be deleted') };
154
+ }
155
+
156
+ /**
157
+ * The local time of a moment, in the format the task XML wants.
158
+ *
159
+ * A one-off task is triggered by a LOCAL time: an ISO time with a `Z` would
160
+ * be read as UTC and the task would fire hours later (or never).
161
+ *
162
+ * @param {Date|number} when
163
+ * @returns {string}
164
+ */
165
+ export function localIso(when) {
166
+ const date = when instanceof Date ? when : new Date(Number(when) || Date.now());
167
+ const pad = (value) => String(value).padStart(2, '0');
168
+ return date.getFullYear()
169
+ + '-' + pad(date.getMonth() + 1)
170
+ + '-' + pad(date.getDate())
171
+ + 'T' + pad(date.getHours())
172
+ + ':' + pad(date.getMinutes())
173
+ + ':' + pad(date.getSeconds());
174
+ }
175
+
176
+ /**
177
+ * Register a systemd user timer.
178
+ *
179
+ * @param {{ unitId: string, delaySeconds: number, command: string[], exec?: Function }} options
180
+ * @returns {{ ok: boolean, unitId: string, error: string|null }}
181
+ */
182
+ export function registerSystemdRun({ unitId, delaySeconds, command, exec = defaultExec }) {
183
+ const seconds = Math.max(1, Math.trunc(Number(delaySeconds) || 1));
184
+ const argv = Array.isArray(command) ? command.map((entry) => String(entry)) : [String(command ?? '')];
185
+ const answer = exec('systemd-run', [
186
+ '--user',
187
+ '--on-active=' + seconds + 's',
188
+ '--unit=' + unitId,
189
+ '--',
190
+ ...argv,
191
+ ]);
192
+ return { ok: answer.ok === true, unitId, error: answer.ok ? null : (answer.error || 'systemd-run refused') };
193
+ }
194
+
195
+ /**
196
+ * Clean the systemd unit up (a failed unit would stay in the list).
197
+ *
198
+ * @param {{ unitId: string, exec?: Function }} options
199
+ * @returns {{ ok: boolean, error: string|null }}
200
+ */
201
+ export function cleanupSystemdUnit({ unitId, exec = defaultExec }) {
202
+ const answer = exec('systemctl', ['--user', 'reset-failed', unitId]);
203
+ return { ok: answer.ok === true, error: answer.ok ? null : (answer.error || null) };
204
+ }
205
+
206
+ /**
207
+ * Start the watchdog as a background process of our own.
208
+ *
209
+ * The pid is written into the state directory (so the process can be cleaned
210
+ * up later) and the process is READ BACK: a background job that died at once
211
+ * is not a watchdog.
212
+ *
213
+ * @param {{
214
+ * delaySeconds: number,
215
+ * command: string,
216
+ * pidPath: string,
217
+ * exec?: Function,
218
+ * writeFile?: Function,
219
+ * isAlive?: (pid: number) => boolean,
220
+ * }} options
221
+ * @returns {{ ok: boolean, pid: number, pidPath: string, error: string|null }}
222
+ */
223
+ export function registerDetachedProcess({
224
+ delaySeconds,
225
+ command,
226
+ pidPath,
227
+ exec = defaultExec,
228
+ writeFile = (target, content) => fs.writeFileSync(target, content, 'utf8'),
229
+ isAlive = (pid) => {
230
+ try {
231
+ process.kill(pid, 0);
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ },
237
+ }) {
238
+ const seconds = Math.max(0, Math.trunc(Number(delaySeconds) || 0));
239
+ // `setsid` detaches the process from this session: it survives the restart
240
+ // that is about to happen.
241
+ const shell = "setsid nohup sh -c 'sleep " + seconds + "; " + command + "' >/dev/null 2>&1 & echo $!";
242
+ const answer = exec('sh', ['-c', shell]);
243
+ if (!answer.ok) {
244
+ return { ok: false, pid: 0, pidPath, error: answer.error || 'the background process could not be started' };
245
+ }
246
+ const pid = Number.parseInt(String(answer.stdout || '').trim(), 10);
247
+ if (!Number.isFinite(pid) || pid <= 0) {
248
+ return { ok: false, pid: 0, pidPath, error: 'the background process did not report a pid' };
249
+ }
250
+ try {
251
+ writeFile(pidPath, String(pid) + '\n', 'utf8');
252
+ } catch {
253
+ // The pid file is a convenience; the watchdog itself is running.
254
+ }
255
+ if (!isAlive(pid)) {
256
+ return { ok: false, pid, pidPath, error: 'the background process is not running' };
257
+ }
258
+ return { ok: true, pid, pidPath, error: null };
259
+ }
260
+
261
+ /**
262
+ * Stop the background process of our own (when it is still around).
263
+ *
264
+ * @param {{ pidPath: string, kill?: Function, readPid?: Function }} options
265
+ * @returns {{ ok: boolean, pid: number, error: string|null }}
266
+ */
267
+ export function removeDetachedProcess({
268
+ pidPath,
269
+ kill = (pid) => { try { process.kill(pid, 'SIGTERM'); } catch { /* it is gone */ } },
270
+ readPid = () => {
271
+ try {
272
+ return Number.parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
273
+ } catch {
274
+ return 0;
275
+ }
276
+ },
277
+ }) {
278
+ const pid = readPid();
279
+ if (!Number.isFinite(pid) || pid <= 0) return { ok: true, pid: 0, error: null };
280
+ kill(pid);
281
+ try {
282
+ fs.rmSync(pidPath, { force: true });
283
+ } catch {
284
+ // The file is gone on the next run anyway.
285
+ }
286
+ return { ok: true, pid, error: null };
287
+ }
288
+
289
+ /**
290
+ * Remove the scheduling item of a tier.
291
+ *
292
+ * @param {{ tier: string, taskName?: string, unitId?: string, pidPath?: string, exec?: Function }} options
293
+ * @returns {{ ok: boolean, error: string|null }}
294
+ */
295
+ export function removeScheduleItem({ tier, taskName = '', unitId = '', pidPath = '', exec = defaultExec }) {
296
+ if (tier === TIERS.SCHEDULED_TASK) return removeScheduledTask({ taskName, exec });
297
+ if (tier === TIERS.SYSTEMD_RUN) return cleanupSystemdUnit({ unitId, exec });
298
+ if (tier === TIERS.DETACHED_PROCESS) return removeDetachedProcess({ pidPath });
299
+ return { ok: true, error: null };
300
+ }
301
+
302
+ /**
303
+ * Where the pid of the background process is written.
304
+ *
305
+ * @param {string} stateDir
306
+ * @returns {string}
307
+ */
308
+ export function detachedPidPath(stateDir) {
309
+ return path.join(stateDir, 'watchdog.pid');
310
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * HOW CAN THE WATCHDOG BE TRIGGERED ON THIS MACHINE?
3
+ *
4
+ * The watchdog is the safety net of the pre-restart backup: it waits for the
5
+ * relaunched host, and when the host never answers, it puts the snapshot back
6
+ * and starts the host again. To do that it has to RUN LATER, and "later" is
7
+ * the part that differs between the machines:
8
+ *
9
+ * 1. `scheduled-task` — a one-off Windows scheduled task (the desktop app
10
+ * and `dsh web` on Windows);
11
+ * 2. `systemd-run` — a systemd user timer (`dsh web` on Linux, WSL with
12
+ * systemd);
13
+ * 3. `detached-process` — a background process of our own (containers, WSL
14
+ * without systemd, everything else that can detach a
15
+ * process).
16
+ *
17
+ * When NONE of them is available there is no safety net at all — and the user
18
+ * has to be told, because "the automatic rollback" is a promise.
19
+ *
20
+ * ONE SOURCE OF TRUTH: `pickWatchdogTier` decides. The capability check of
21
+ * the plugin (what the settings page shows) and the watchdog that is actually
22
+ * registered ask THE SAME function, so the warning can never drift away from
23
+ * the behaviour.
24
+ */
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+
28
+ /** The ways a watchdog can be triggered. */
29
+ export const TIERS = Object.freeze({
30
+ SCHEDULED_TASK: 'scheduled-task',
31
+ SYSTEMD_RUN: 'systemd-run',
32
+ DETACHED_PROCESS: 'detached-process',
33
+ NONE: 'none',
34
+ });
35
+
36
+ /** What each tier means, for the report and for the settings page. */
37
+ export const TIER_LABELS = Object.freeze({
38
+ 'scheduled-task': 'a one-off Windows scheduled task',
39
+ 'systemd-run': 'a systemd user timer',
40
+ 'detached-process': 'a detached background process',
41
+ none: 'nothing on this machine',
42
+ });
43
+
44
+ /**
45
+ * Run a command and report whether it worked.
46
+ *
47
+ * @param {string} file
48
+ * @param {string[]} args
49
+ * @returns {{ ok: boolean, stdout: string, error: string|null }}
50
+ */
51
+ export function defaultExec(file, args) {
52
+ try {
53
+ const stdout = execFileSync(file, args, { encoding: 'utf8', timeout: 15000, windowsHide: true });
54
+ return { ok: true, stdout: typeof stdout === 'string' ? stdout : '', error: null };
55
+ } catch (error) {
56
+ return {
57
+ ok: false,
58
+ stdout: error && typeof error.stdout === 'string' ? error.stdout : '',
59
+ error: error && error.message ? error.message : String(error),
60
+ };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Is this command there?
66
+ *
67
+ * @param {Function} exec
68
+ * @param {string} file
69
+ * @param {string[]} args
70
+ * @returns {boolean}
71
+ */
72
+ export function commandWorks(exec, file, args) {
73
+ const answer = exec(file, args);
74
+ return answer !== null && answer !== undefined && answer.ok === true;
75
+ }
76
+
77
+ /**
78
+ * WHAT THIS MACHINE CAN DO.
79
+ *
80
+ * @param {{ platform?: string, exec?: Function }} [options]
81
+ * @returns {{ platform: string, hasSchtasks: boolean, hasSystemdRun: boolean, hasAt: boolean, canDetach: boolean }}
82
+ */
83
+ export function detectWatchdogCapabilities({ platform = process.platform, exec = defaultExec } = {}) {
84
+ const windows = platform === 'win32';
85
+ return {
86
+ platform,
87
+ hasSchtasks: windows ? commandWorks(exec, 'schtasks', ['/?']) : false,
88
+ hasSystemdRun: windows ? false : commandWorks(exec, 'systemd-run', ['--version']),
89
+ hasAt: windows ? false : commandWorks(exec, 'at', ['--version']),
90
+ canDetach: windows ? false : commandWorks(exec, 'setsid', ['--version']),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * WHICH TIER DOES THIS MACHINE GET?
96
+ *
97
+ * A pure function: it takes what the machine can do and says which tier to
98
+ * use — and WHY. The reason goes into the report, so a reader of
99
+ * `last-watchdog-report.json` knows what happened without guessing.
100
+ *
101
+ * @param {{
102
+ * platform?: string,
103
+ * hasSchtasks?: boolean,
104
+ * hasSystemdRun?: boolean,
105
+ * hasAt?: boolean,
106
+ * canDetach?: boolean,
107
+ * }} [options]
108
+ * @returns {{ tier: string, reason: string }}
109
+ */
110
+ export function pickWatchdogTier({
111
+ platform = process.platform,
112
+ hasSchtasks = false,
113
+ hasSystemdRun = false,
114
+ hasAt = false,
115
+ canDetach = false,
116
+ } = {}) {
117
+ if (platform === 'win32') {
118
+ if (hasSchtasks) {
119
+ return {
120
+ tier: TIERS.SCHEDULED_TASK,
121
+ reason: 'this Windows can register a one-off scheduled task (schtasks)',
122
+ };
123
+ }
124
+ return {
125
+ tier: TIERS.NONE,
126
+ reason: 'this Windows cannot register a scheduled task (schtasks is not usable here)',
127
+ };
128
+ }
129
+ if (hasSystemdRun) {
130
+ return {
131
+ tier: TIERS.SYSTEMD_RUN,
132
+ reason: 'systemd user timers are available here (systemd-run)',
133
+ };
134
+ }
135
+ if (canDetach) {
136
+ return {
137
+ tier: TIERS.DETACHED_PROCESS,
138
+ reason: 'there is no systemd here'
139
+ + (hasAt
140
+ ? ' (the machine has at, but the plugin starts a process of its own: no daemon, nothing to configure)'
141
+ : ' (no systemd and no at): the plugin starts a process of its own'),
142
+ };
143
+ }
144
+ return {
145
+ tier: TIERS.NONE,
146
+ reason: 'this machine can neither schedule a task nor detach a process of its own',
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Does this tier give the user the automatic rollback?
152
+ *
153
+ * @param {string} tier
154
+ * @returns {boolean}
155
+ */
156
+ export function tierProvidesRollback(tier) {
157
+ return typeof tier === 'string' && tier !== '' && tier !== TIERS.NONE;
158
+ }
159
+
160
+ /**
161
+ * The tier from a capability record that ALSO carries a real proof.
162
+ *
163
+ * On Windows the plugin does not trust "the command is there": it really
164
+ * registers a task and reads it back (see lib/scheduler-capability.js). That
165
+ * proof is the answer to "hasSchtasks" — the same answer the watchdog uses
166
+ * when it registers its own task.
167
+ *
168
+ * @param {{
169
+ * platform?: string,
170
+ * probeOk?: boolean|null,
171
+ * hasSystemdRun?: boolean,
172
+ * hasAt?: boolean,
173
+ * canDetach?: boolean,
174
+ * }} options
175
+ * @returns {{ tier: string, reason: string }}
176
+ */
177
+ export function tierWithProof({ platform = process.platform, probeOk = null, hasSystemdRun = false, hasAt = false, canDetach = false } = {}) {
178
+ const hasSchtasks = platform === 'win32'
179
+ ? probeOk === true
180
+ : false;
181
+ return pickWatchdogTier({ platform, hasSchtasks, hasSystemdRun, hasAt, canDetach });
182
+ }