@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.5
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/LICENSE +21 -0
- package/README.md +50 -2
- package/bin/projectfreeze.js +4 -0
- package/docs/interfaces.md +98 -0
- package/docs/operations.md +42 -0
- package/examples/demo.js +72 -0
- package/examples/stream-test.mjs +71 -0
- package/examples/workload.js +29 -0
- package/helpers/.gitkeep +0 -0
- package/helpers/projectfreeze-darwin-arm64 +0 -0
- package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
- package/helpers/projectfreeze-darwin-x64 +0 -0
- package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
- package/helpers/projectfreeze-linux-arm64 +0 -0
- package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
- package/helpers/projectfreeze-linux-x64 +0 -0
- package/helpers/projectfreeze-linux-x64.sha256 +1 -0
- package/helpers/projectfreeze-win32-arm64.exe +0 -0
- package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
- package/helpers/projectfreeze-win32-x64.exe +0 -0
- package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
- package/package.json +17 -4
- package/src/cli.js +135 -0
- package/src/contracts.d.ts +28 -0
- package/src/discovery.js +112 -0
- package/src/engine.js +820 -0
- package/src/errors.js +20 -0
- package/src/integrations.js +195 -0
- package/src/inventory.js +37 -0
- package/src/ipc.js +258 -0
- package/src/line-menu.js +147 -0
- package/src/mcp.js +113 -0
- package/src/platform.js +320 -0
- package/src/recipe.js +69 -0
- package/src/store.js +61 -0
- package/src/supervisors.js +163 -0
- package/src/systemd.js +104 -0
- package/src/tui.js +596 -0
- package/src/windows-supervisors.ps1 +83 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { readFile, readlink, rmdir } from 'node:fs/promises';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { candidates, directory, projectId, summarize } from './discovery.js';
|
|
7
|
+
import { Fault, problem } from './errors.js';
|
|
8
|
+
import { checkpoint, composeTargets, containerAction, restore } from './integrations.js';
|
|
9
|
+
import { bootId, command, containerPids, control, executable, identity, key, localPorts, matches, scan, stageLaunch, windows } from './platform.js';
|
|
10
|
+
import { localServices } from './inventory.js';
|
|
11
|
+
import { launchEnv, prepareCgroup, recipe } from './recipe.js';
|
|
12
|
+
import { serviceAction, serviceTargets, systemdPids } from './systemd.js';
|
|
13
|
+
import { observeSupervisors, supervisorAction, supervisorTargets } from './supervisors.js';
|
|
14
|
+
|
|
15
|
+
const suspended = row => row.state === 'T';
|
|
16
|
+
const stopped = row => row.state === 'Z' || row.state === 'X';
|
|
17
|
+
const alive = (rows, target) => rows.find(row => matches(row, target) && !stopped(row));
|
|
18
|
+
const visible = (rows, project) => rows.filter(row => !stopped(row) && project.members.some(target => matches(target, row)));
|
|
19
|
+
const runningService = (containers, units) => containers.some(target => !target.shared && target.running) ||
|
|
20
|
+
units.some(unit => !unit.shared && !unit.timer && unit.state.ActiveState === 'active');
|
|
21
|
+
const newProject = (id, cwd, instance) => ({ id, cwd, instance, name: instance ? `${basename(cwd)} / ${instance}` : basename(cwd),
|
|
22
|
+
members: [], excluded: [], state: 'registered' });
|
|
23
|
+
const changes = new Set(['adopt', 'run', 'freeze', 'resume', 'sleep', 'kill', 'wake', 'checkpoint', 'restore', 'reconcile']);
|
|
24
|
+
|
|
25
|
+
async function listening({ address, port }) {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
const socket = createConnection({ host: address, port });
|
|
28
|
+
const done = value => { socket.destroy(); resolve(value); };
|
|
29
|
+
socket.once('connect', () => done(true));
|
|
30
|
+
socket.once('error', () => done(false));
|
|
31
|
+
socket.setTimeout(500, () => done(false));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Engine {
|
|
36
|
+
constructor(store, state) {
|
|
37
|
+
this.store = store;
|
|
38
|
+
this.state = state;
|
|
39
|
+
this.current = null;
|
|
40
|
+
this.tracking = false;
|
|
41
|
+
this.closing = false;
|
|
42
|
+
this.store.recover();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async dispatch(action, input = {}) {
|
|
46
|
+
if (action === 'history') return this.store.history();
|
|
47
|
+
if (action === 'operation') return this.store.operation(input.id);
|
|
48
|
+
if (action === 'cancel') {
|
|
49
|
+
if (this.current?.op.id !== input.id) throw new Fault('NOT_FOUND', 'Operation is no longer running.');
|
|
50
|
+
this.current.cancelled = true;
|
|
51
|
+
return { id: input.id, cancellation: 'requested' };
|
|
52
|
+
}
|
|
53
|
+
if (action === 'services') return localServices();
|
|
54
|
+
if (['list', 'discover', 'inspect', 'listeners', 'ports'].includes(action)) return this.inspect(action, input);
|
|
55
|
+
if (!changes.has(action)) throw new Fault('USAGE', 'Unknown operation.');
|
|
56
|
+
while (this.tracking) await delay(25);
|
|
57
|
+
if (this.current || this.closing) throw new Fault('BUSY', 'An operation is running or the daemon is shutting down.');
|
|
58
|
+
const ids = [...new Set(input.ids ?? [])];
|
|
59
|
+
if (!['adopt', 'run', 'reconcile'].includes(action) && (!ids.length || ids.length > 32 || !ids.every(id => /^p-[a-f0-9]{16}$/.test(id)))) {
|
|
60
|
+
throw new Fault('USAGE', 'Provide one or more registered project IDs.');
|
|
61
|
+
}
|
|
62
|
+
const op = { id: randomUUID(), action, projects: ids, status: 'running', started: new Date().toISOString(), outcomes: [] };
|
|
63
|
+
const job = { op, cancelled: false };
|
|
64
|
+
this.store.journal(op);
|
|
65
|
+
this.current = job;
|
|
66
|
+
void this.work(job, input).catch(() => { this.current = null; });
|
|
67
|
+
return { id: op.id, status: op.status };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async inspect(action, input) {
|
|
71
|
+
const projects = this.store.projects();
|
|
72
|
+
const rows = await scan();
|
|
73
|
+
for (const project of projects) if (project.root) await this.track(project, rows).catch(() => {});
|
|
74
|
+
const discovered = await candidates(projects, rows);
|
|
75
|
+
const observed = discovered.map(group => ({ ...group, processes: group.members, processCount: group.members.length,
|
|
76
|
+
ports: group.members.flatMap(member => member.ports), dependencies: [], containers: [], systemd: [], supervisors: [], restart: { configured: false } }));
|
|
77
|
+
if (action === 'discover') return observed;
|
|
78
|
+
const discoveredById = new Map(observed.map(row => [row.id, row]));
|
|
79
|
+
const managed = projects.map(project => summarize(project, rows, discoveredById.get(projectId(project.cwd))));
|
|
80
|
+
const combined = [...managed, ...observed.filter(candidate => !projects.some(p => p.id === candidate.id))];
|
|
81
|
+
if (action === 'list') return combined;
|
|
82
|
+
if (action === 'listeners' || action === 'ports') {
|
|
83
|
+
const association = new Map(observed.flatMap(project => project.processes.map(row => [row.pid, { project: project.id, name: project.name, scope: 'observed' }])));
|
|
84
|
+
for (const project of managed) {
|
|
85
|
+
for (const row of project.processes) association.set(row.pid, { project: project.id, name: project.name, scope: 'managed' });
|
|
86
|
+
}
|
|
87
|
+
const images = new Map(rows.map(row => [row.pid, row.image]));
|
|
88
|
+
const endpoints = action === 'ports' ? await localPorts(rows) : rows.flatMap(row => row.ports.map(port => ({ ...port, pid: row.pid })));
|
|
89
|
+
return [...new Map(endpoints.map(row => [`${row.protocol}/${row.address}/${row.port}/${row.pid}`, row])).values()]
|
|
90
|
+
.map(row => ({ ...row, image: images.get(row.pid), scope: 'unassigned', ...association.get(row.pid) }))
|
|
91
|
+
.sort((a, b) => a.port - b.port || a.address.localeCompare(b.address) || a.pid - b.pid);
|
|
92
|
+
}
|
|
93
|
+
const project = combined.find(p => p.id === input.id);
|
|
94
|
+
if (!project) throw new Fault('NOT_FOUND', 'Unknown project ID.');
|
|
95
|
+
const registered = projects.find(row => row.id === input.id);
|
|
96
|
+
const containers = project.containers.map(target => ({ ...target, shared: target.shared || projects.some(other =>
|
|
97
|
+
other.id !== project.id && other.recipe?.containers?.some(policy => policy.project === target.project && policy.service === target.service)) }));
|
|
98
|
+
const systemd = project.systemd.map(target => ({ ...target, shared: target.shared || projects.some(other =>
|
|
99
|
+
other.id !== project.id && other.recipe?.systemd?.some(policy => policy.unit === target.unit)) }));
|
|
100
|
+
const supervisors = registered ? await observeSupervisors(registered, projects, rows).then(targets => targets.map(({ key, kind, name, state, pid, enabled, active, shared }) =>
|
|
101
|
+
({ key, kind, name, state, pid, enabled, active, shared })), error => project.supervisors.map(target => ({ ...target, error: problem(error) }))) : [];
|
|
102
|
+
return { ...project, containers, systemd, supervisors, candidates: discoveredById.get(projectId(project.cwd))?.processes ?? [],
|
|
103
|
+
operations: this.store.history().filter(op => op.projects.includes(input.id)).slice(0, 12) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
check(job) { if (job.cancelled) throw new Fault('CANCELLED', 'Cancelled at a resource boundary. Completed effects remain in the journal.'); }
|
|
107
|
+
pending(job, project, text, effect) {
|
|
108
|
+
job.op.pending = text;
|
|
109
|
+
if (effect) project.uncertainEffect = { operation: job.op.id, ...effect };
|
|
110
|
+
this.store.commit(project, job.op);
|
|
111
|
+
}
|
|
112
|
+
record(job, project, target, status, error) {
|
|
113
|
+
if (!error) {
|
|
114
|
+
delete job.op.pending;
|
|
115
|
+
if (project?.uncertainEffect?.operation === job.op.id) delete project.uncertainEffect;
|
|
116
|
+
}
|
|
117
|
+
job.op.outcomes.push({ target, status, ...(error ? problem(error) : {}) });
|
|
118
|
+
this.store.commit(project, job.op);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async work(job, input) {
|
|
122
|
+
const { op } = job;
|
|
123
|
+
try {
|
|
124
|
+
if (op.action === 'adopt') await this.adopt(job, input);
|
|
125
|
+
else if (op.action === 'run') await this.run(job, input);
|
|
126
|
+
else if (op.action === 'reconcile') await this.reconcile(job, input.ids);
|
|
127
|
+
else {
|
|
128
|
+
const all = this.store.projects();
|
|
129
|
+
const byId = new Map(all.map(project => [project.id, project]));
|
|
130
|
+
const ordered = [];
|
|
131
|
+
const visiting = new Set();
|
|
132
|
+
const add = id => {
|
|
133
|
+
if (visiting.has(id)) throw new Fault('USAGE', 'Dependency graph contains a cycle.');
|
|
134
|
+
if (ordered.includes(id)) return;
|
|
135
|
+
const project = byId.get(id);
|
|
136
|
+
if (!project) throw new Fault('NOT_FOUND', 'Project or registered dependency is missing.');
|
|
137
|
+
visiting.add(id);
|
|
138
|
+
for (const dep of project.recipe?.dependencies ?? []) {
|
|
139
|
+
if (op.action === 'wake' || op.projects.includes(dep)) add(dep);
|
|
140
|
+
}
|
|
141
|
+
visiting.delete(id);
|
|
142
|
+
ordered.push(id);
|
|
143
|
+
};
|
|
144
|
+
op.projects.forEach(add);
|
|
145
|
+
if (['freeze', 'sleep'].includes(op.action)) ordered.reverse();
|
|
146
|
+
for (const id of ordered) {
|
|
147
|
+
this.check(job);
|
|
148
|
+
const project = this.store.project(id);
|
|
149
|
+
try {
|
|
150
|
+
if (project.uncertainEffect) {
|
|
151
|
+
throw new Fault('PARTIAL', 'Reconcile the interrupted resource effect before changing project state.');
|
|
152
|
+
}
|
|
153
|
+
if (op.action === 'freeze') await this.freeze(job, project);
|
|
154
|
+
if (op.action === 'resume') await this.resume(job, project);
|
|
155
|
+
if (op.action === 'sleep') await this.sleep(job, project, input);
|
|
156
|
+
if (op.action === 'kill') await this.kill(job, project, input);
|
|
157
|
+
if (op.action === 'wake') await this.wake(job, project);
|
|
158
|
+
if (op.action === 'checkpoint') await this.capture(job, project, input);
|
|
159
|
+
if (op.action === 'restore') await this.restore(job, project, input);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (['freezing', 'resuming', 'sleeping-in-progress', 'terminating', 'waking', 'restoring'].includes(project.state)) project.state = 'partial';
|
|
162
|
+
this.record(job, project, id, 'failed', error);
|
|
163
|
+
if (error.code === 'CANCELLED' || ['wake', 'sleep'].includes(op.action)) break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const failures = op.outcomes.filter(outcome => outcome.status === 'failed');
|
|
168
|
+
op.status = job.cancelled ? 'cancelled' : failures.length ? (op.outcomes.length === 1 ? 'failed' : 'partial') : 'complete';
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.record(job, undefined, op.action, 'failed', error);
|
|
171
|
+
op.status = job.cancelled ? 'cancelled' : 'failed';
|
|
172
|
+
} finally {
|
|
173
|
+
op.ended = new Date().toISOString();
|
|
174
|
+
this.store.journal(op);
|
|
175
|
+
this.current = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async adopt(job, input) {
|
|
180
|
+
const cwd = await directory(input.cwd);
|
|
181
|
+
const projects = this.store.projects();
|
|
182
|
+
const configuration = input.recipe ? await recipe({ ...input.recipe, cwd }) : undefined;
|
|
183
|
+
const selected = input.id === undefined ? undefined : projects.find(project => project.id === input.id);
|
|
184
|
+
if (input.id !== undefined && (!selected || selected.cwd !== cwd ||
|
|
185
|
+
(configuration?.instance !== undefined && configuration.instance !== selected.instance))) {
|
|
186
|
+
throw new Fault('DENIED', 'The selected project does not match the reviewed directory and workload instance.');
|
|
187
|
+
}
|
|
188
|
+
const instance = selected ? selected.instance : configuration?.instance;
|
|
189
|
+
const id = projectId(cwd, instance);
|
|
190
|
+
const project = selected ?? projects.find(p => p.id === id) ?? newProject(id, cwd, instance);
|
|
191
|
+
if (!['registered', 'exited', 'running', 'adopted'].includes(project.state)) throw new Fault('BUSY', 'Reconcile the current project state before changing ownership.');
|
|
192
|
+
if (input.confirm !== true || !Array.isArray(input.members) || !input.members.length || input.members.length > 256) {
|
|
193
|
+
throw new Fault('USAGE', 'Inspect discovery and explicitly approve the listed PID@creation@boot identities.');
|
|
194
|
+
}
|
|
195
|
+
const rows = await scan();
|
|
196
|
+
const group = (await candidates(projects, rows)).find(candidate => candidate.id === projectId(cwd));
|
|
197
|
+
const excluded = Array.isArray(input.exclude) ? input.exclude : [];
|
|
198
|
+
for (const target of [...input.members, ...excluded]) {
|
|
199
|
+
if (!group?.members.some(row => matches(row, target)) && !project.members.some(row => matches(row, target))) {
|
|
200
|
+
throw new Fault('DENIED', 'An approved identity no longer matches the current candidates. Review discovery again.');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const target of input.members) {
|
|
204
|
+
if (!alive(rows, target) || excluded.some(item => matches(item, target))) throw new Fault('DENIED', 'A selected process is stale or excluded.');
|
|
205
|
+
if (!project.members.some(item => matches(item, target))) project.members.push({ ...identity(target), source: 'adopted' });
|
|
206
|
+
}
|
|
207
|
+
project.members = project.members.filter(target => !excluded.some(item => matches(item, target)));
|
|
208
|
+
project.excluded.push(...excluded.map(identity));
|
|
209
|
+
project.state = project.root ? 'running' : 'adopted';
|
|
210
|
+
if (configuration) {
|
|
211
|
+
project.recipe = { ...configuration, instance };
|
|
212
|
+
await supervisorTargets(project, projects, true);
|
|
213
|
+
}
|
|
214
|
+
job.op.projects = [id];
|
|
215
|
+
this.record(job, project, id, 'adopted');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async run(job, input) {
|
|
219
|
+
const configuration = await recipe(input.recipe);
|
|
220
|
+
const { cwd, instance } = configuration;
|
|
221
|
+
const id = projectId(cwd, instance);
|
|
222
|
+
const project = this.store.projects().find(p => p.id === id) ?? newProject(id, cwd, instance);
|
|
223
|
+
if (project.uncertainEffect || !['registered', 'exited', 'sleeping'].includes(project.state)) {
|
|
224
|
+
throw new Fault('BUSY', 'This project already owns a workload or has an interrupted effect. Inspect and reconcile before launching another.');
|
|
225
|
+
}
|
|
226
|
+
if (visible(await scan(false), project).length) throw new Fault('BUSY', 'Verified processes are already running for this project.');
|
|
227
|
+
const all = this.store.projects();
|
|
228
|
+
for (const id of configuration.dependencies) {
|
|
229
|
+
if (id === project.id || !all.some(p => p.id === id && p.state === 'running')) throw new Fault('UNAVAILABLE', 'Start registered dependencies before running this recipe.');
|
|
230
|
+
}
|
|
231
|
+
project.recipe = configuration;
|
|
232
|
+
project.restarts = 0;
|
|
233
|
+
job.op.projects = [id];
|
|
234
|
+
if (configuration.systemd.length) await serviceTargets(project, all);
|
|
235
|
+
await supervisorTargets(project, all, true);
|
|
236
|
+
try { await this.launch(job, project); }
|
|
237
|
+
catch (error) { project.state = 'partial'; this.record(job, project, id, 'failed', error); }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async launch(job, project) {
|
|
241
|
+
const config = project.recipe;
|
|
242
|
+
if (!config) throw new Fault('UNAVAILABLE', 'This project has no reviewed restart recipe.');
|
|
243
|
+
const env = launchEnv(config, process.env);
|
|
244
|
+
if (config.ready && await listening(config.ready)) throw new Fault('BUSY', 'The readiness port already accepts connections.');
|
|
245
|
+
if (project.cgroup) await rmdir(project.cgroup).catch(() => {});
|
|
246
|
+
project.cgroup = await prepareCgroup(config.cgroup, `${project.id}-${randomUUID()}`);
|
|
247
|
+
project.job = windows ? `Local\\ProjectFreeze-${randomUUID()}` : undefined;
|
|
248
|
+
project.sleepingIntent = false;
|
|
249
|
+
project.state = 'waking';
|
|
250
|
+
project.launchReceipt = randomUUID();
|
|
251
|
+
this.pending(job, project, 'stage workload launch');
|
|
252
|
+
const staged = await stageLaunch(config.argv, project.cwd, env, project.job, project.cgroup, join(this.state, `exit-${project.launchReceipt}`));
|
|
253
|
+
try {
|
|
254
|
+
project.root = staged.root;
|
|
255
|
+
project.members = [...project.members.filter(member => member.source === 'adopted' && member.suspended), { ...staged.root, source: 'launched' }];
|
|
256
|
+
this.pending(job, project, 'release verified staged workload', { kind: 'launch' });
|
|
257
|
+
this.check(job);
|
|
258
|
+
staged.release();
|
|
259
|
+
} catch (error) { staged.abort(); throw error; }
|
|
260
|
+
await this.ready(job, project);
|
|
261
|
+
project.state = 'running';
|
|
262
|
+
this.record(job, project, project.id, 'running');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async ready(job, project) {
|
|
266
|
+
const deadline = Date.now() + (project.recipe?.timeout ?? 10000);
|
|
267
|
+
do {
|
|
268
|
+
this.check(job);
|
|
269
|
+
const rows = await scan(Boolean(project.recipe?.ready));
|
|
270
|
+
await this.track(project, rows);
|
|
271
|
+
if (project.root && !visible(rows, project).length) {
|
|
272
|
+
if (Date.now() + 250 >= deadline) throw new Fault('PARTIAL', 'Workload exited before readiness.');
|
|
273
|
+
await delay(200);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const port = project.recipe?.ready;
|
|
277
|
+
if (!port || (visible(rows, project).some(row => row.ports.some(p => p.port === port.port &&
|
|
278
|
+
[port.address, '0.0.0.0', '*', '::', '0:0:0:0:0:0:0:0'].includes(p.address))) && await listening(port))) return;
|
|
279
|
+
await delay(200);
|
|
280
|
+
} while (Date.now() < deadline);
|
|
281
|
+
throw new Fault('PARTIAL', 'Readiness deadline expired without a verified workload listener.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async track(project, rows) {
|
|
285
|
+
if (!project.root) return;
|
|
286
|
+
let contained = null;
|
|
287
|
+
try { contained = await containerPids(project); }
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (!windows || error.code !== 'NOT_FOUND') throw error;
|
|
290
|
+
}
|
|
291
|
+
const root = alive(rows, project.root);
|
|
292
|
+
const groupProof = root ?? (!windows && !project.cgroup && rows.find(row => row.group === project.root.pid &&
|
|
293
|
+
row.session === project.root.pid && project.members.some(member => matches(member, row))));
|
|
294
|
+
const foreign = new Set(this.store.projects().filter(other => other.id !== project.id).flatMap(other => other.members.map(key)));
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
if (stopped(row) || foreign.has(key(row)) || project.excluded.some(item => matches(item, row)) ||
|
|
297
|
+
project.members.some(item => matches(item, row))) continue;
|
|
298
|
+
if (contained?.has(windows ? key(row) : row.pid) || (!contained && groupProof && row.group === project.root.pid && row.group === groupProof.group)) {
|
|
299
|
+
project.members.push({ ...identity(row), source: 'contained' });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async restartEligible(project, rows) {
|
|
305
|
+
if (project.recipe?.restart !== 'on-failure' || project.sleepingIntent || !['running', 'exited'].includes(project.state) ||
|
|
306
|
+
(project.restarts ?? 0) >= 3 || visible(rows, project).length || project.root?.boot !== await bootId() ||
|
|
307
|
+
!/^[a-f0-9-]{36}$/.test(project.launchReceipt ?? '')) return false;
|
|
308
|
+
const receipt = await readFile(join(this.state, `exit-${project.launchReceipt}`), 'utf8').catch(() => '');
|
|
309
|
+
return /^[1-9][0-9]{0,9}\n$/.test(receipt);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async poll() {
|
|
313
|
+
if (this.current || this.tracking || this.closing) return;
|
|
314
|
+
this.tracking = true;
|
|
315
|
+
let restart;
|
|
316
|
+
try {
|
|
317
|
+
const projects = this.store.projects();
|
|
318
|
+
if (!projects.length) return;
|
|
319
|
+
const rows = await scan(false);
|
|
320
|
+
for (const project of projects) {
|
|
321
|
+
if (!project.members.length && !project.recipe?.systemd?.length && !project.recipe?.containers?.length) continue;
|
|
322
|
+
const before = JSON.stringify(project);
|
|
323
|
+
try { await this.track(project, rows); }
|
|
324
|
+
catch { if (project.state === 'frozen') { project.state = 'partial'; this.store.save(project); } continue; }
|
|
325
|
+
const live = visible(rows, project);
|
|
326
|
+
if (!restart && await this.restartEligible(project, rows)) restart = project.id;
|
|
327
|
+
if (['running', 'adopted'].includes(project.state) && !live.length && !project.recipe?.supervisors?.length) {
|
|
328
|
+
try {
|
|
329
|
+
const containers = await composeTargets(project, projects);
|
|
330
|
+
const units = await serviceTargets(project, projects);
|
|
331
|
+
if (!runningService(containers, units)) project.state = 'exited';
|
|
332
|
+
} catch { project.state = 'partial'; }
|
|
333
|
+
}
|
|
334
|
+
if (project.state === 'frozen') {
|
|
335
|
+
try {
|
|
336
|
+
const units = await serviceTargets(project, projects);
|
|
337
|
+
const containers = await composeTargets(project, projects);
|
|
338
|
+
const external = await observeSupervisors(project, projects, rows);
|
|
339
|
+
const servicePids = new Set([...(await Promise.all(units.map(systemdPids))).flat(), ...external.map(target => target.pid)]);
|
|
340
|
+
const observed = live.filter(row => !servicePids.has(row.pid));
|
|
341
|
+
if ((!live.length && !units.some(unit => unit.state?.FreezerState === 'frozen') && !containers.some(target => target.paused) &&
|
|
342
|
+
!external.some(t => t.state === 'Paused' || project.disabledTasks?.includes(t.key))) ||
|
|
343
|
+
observed.some(row => !suspended(row)) || units.some(unit => unit.state?.ActiveState === 'active' && unit.state.FreezerState !== 'frozen') ||
|
|
344
|
+
containers.some(target => target.running && !target.paused) || external.some(t => !t.shared && (t.state === 'Running' || t.enabled || t.active))) project.state = 'partial';
|
|
345
|
+
} catch { project.state = 'partial'; }
|
|
346
|
+
}
|
|
347
|
+
if (JSON.stringify(project) !== before) this.store.save(project);
|
|
348
|
+
}
|
|
349
|
+
} finally { this.tracking = false; }
|
|
350
|
+
if (restart && !this.current && !this.closing) {
|
|
351
|
+
const project = this.store.project(restart);
|
|
352
|
+
project.restarts = (project.restarts ?? 0) + 1;
|
|
353
|
+
project.state = 'exited';
|
|
354
|
+
this.store.save(project);
|
|
355
|
+
await this.dispatch('wake', { ids: [restart] });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async freeze(job, project) {
|
|
360
|
+
if (!['running', 'adopted', 'partial', 'frozen', 'registered'].includes(project.state)) throw new Fault('BUSY', 'Project is not in a freezable state.');
|
|
361
|
+
const all = this.store.projects();
|
|
362
|
+
if (all.some(other => other.id !== project.id && !job.op.projects.includes(other.id) && other.recipe?.dependencies.includes(project.id) &&
|
|
363
|
+
!['sleeping', 'exited', 'registered', 'frozen'].includes(other.state))) {
|
|
364
|
+
throw new Fault('BUSY', 'An active unselected project still uses this dependency.');
|
|
365
|
+
}
|
|
366
|
+
const targets = await composeTargets(project, all);
|
|
367
|
+
const units = await serviceTargets(project, all);
|
|
368
|
+
const external = await observeSupervisors(project, all, await scan(false));
|
|
369
|
+
if (external.some(target => !target.shared && (target.kind === 'task' ? target.active : !['Running', 'Paused', 'Stopped'].includes(target.state)))) {
|
|
370
|
+
throw new Fault('BUSY', 'A scheduled task or service is active or transitioning. Wait for a stable state before freezing.');
|
|
371
|
+
}
|
|
372
|
+
project.state = 'freezing';
|
|
373
|
+
project.frozenContainers ??= [];
|
|
374
|
+
this.pending(job, project, 'freeze barrier started');
|
|
375
|
+
await this.pauseSupervisors(job, project, external);
|
|
376
|
+
for (const target of units.filter(unit => unit.timer && unit.state.ActiveState === 'active')) {
|
|
377
|
+
await this.changeUnit(job, project, 'stop', target, 'stoppedUnits');
|
|
378
|
+
}
|
|
379
|
+
const afterTimers = await serviceTargets(project, all);
|
|
380
|
+
for (const target of afterTimers.filter(unit => !unit.shared && !unit.timer && unit.state.ActiveState === 'active' && unit.state.FreezerState !== 'frozen')) {
|
|
381
|
+
await this.changeUnit(job, project, 'freeze', target, 'frozenUnits');
|
|
382
|
+
}
|
|
383
|
+
for (const target of targets) {
|
|
384
|
+
this.check(job);
|
|
385
|
+
if (target.shared) { this.record(job, project, `${target.project}/${target.service}`, 'shared'); continue; }
|
|
386
|
+
if (!target.running || target.paused) continue;
|
|
387
|
+
this.pending(job, project, `pause container ${target.id}`);
|
|
388
|
+
await containerAction('pause', target.id);
|
|
389
|
+
project.frozenContainers.push(target.id);
|
|
390
|
+
this.record(job, project, target.id, 'frozen');
|
|
391
|
+
}
|
|
392
|
+
let previous;
|
|
393
|
+
const deadline = Date.now() + 10000;
|
|
394
|
+
do {
|
|
395
|
+
this.check(job);
|
|
396
|
+
const rows = await scan(false);
|
|
397
|
+
await this.track(project, rows);
|
|
398
|
+
const servicePids = new Set([...(await Promise.all(units.map(systemdPids))).flat(), ...external.map(target => target.pid)]);
|
|
399
|
+
const members = visible(rows, project).filter(row => !servicePids.has(row.pid));
|
|
400
|
+
if (!members.length && !targets.some(t => t.running && !t.shared) && !units.some(u => !u.shared && u.state.ActiveState === 'active') &&
|
|
401
|
+
!external.some(t => !t.shared && (t.state === 'Running' || t.state === 'Paused' || t.enabled))) {
|
|
402
|
+
throw new Fault('NOT_FOUND', 'No live owned resources are available to freeze.');
|
|
403
|
+
}
|
|
404
|
+
for (const member of members.filter(row => !suspended(row))) {
|
|
405
|
+
this.check(job);
|
|
406
|
+
this.pending(job, project, `freeze process ${key(member)}`, { kind: 'process', verb: 'freeze', identity: identity(member) });
|
|
407
|
+
if (await control('freeze', member)) project.members.find(row => matches(row, member)).suspended = true;
|
|
408
|
+
this.record(job, project, String(member.pid), 'freeze-requested');
|
|
409
|
+
}
|
|
410
|
+
const signature = members.map(key).sort().join(',');
|
|
411
|
+
if (signature === previous && members.every(suspended)) {
|
|
412
|
+
const verified = await serviceTargets(project, all);
|
|
413
|
+
if (verified.some(u => !u.shared && (u.timer ? u.state.ActiveState === 'active' : u.state.ActiveState === 'active' && u.state.FreezerState !== 'frozen'))) {
|
|
414
|
+
throw new Fault('PARTIAL', 'A user systemd unit changed state during the freeze barrier.');
|
|
415
|
+
}
|
|
416
|
+
const current = await observeSupervisors(project, all, rows);
|
|
417
|
+
if (current.some(t => !t.shared && (t.kind === 'task' ? t.enabled || t.active : !['Paused', 'Stopped'].includes(t.state)))) {
|
|
418
|
+
throw new Fault('PARTIAL', 'A supervised workload or task remains active.');
|
|
419
|
+
}
|
|
420
|
+
project.state = 'frozen';
|
|
421
|
+
this.record(job, project, project.id, 'frozen');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
previous = signature;
|
|
425
|
+
await delay(125);
|
|
426
|
+
} while (Date.now() < deadline);
|
|
427
|
+
throw new Fault('PARTIAL', 'Freeze barrier did not converge. Inspect history and resume the resources this operation suspended.');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async resume(job, project) {
|
|
431
|
+
const targets = await composeTargets(project, this.store.projects());
|
|
432
|
+
const units = await serviceTargets(project, this.store.projects());
|
|
433
|
+
const external = await observeSupervisors(project, this.store.projects(), await scan(false));
|
|
434
|
+
project.state = 'resuming';
|
|
435
|
+
this.pending(job, project, 'resume recorded suspensions');
|
|
436
|
+
await this.restoreSupervisors(job, project, external, 'resume', 'pausedSupervisors');
|
|
437
|
+
await this.restoreUnits(job, project, units, 'thaw', 'frozenUnits');
|
|
438
|
+
const rows = await scan(false);
|
|
439
|
+
for (const member of project.members.filter(m => m.suspended)) {
|
|
440
|
+
this.check(job);
|
|
441
|
+
const live = alive(rows, member);
|
|
442
|
+
this.pending(job, project, `resume process ${key(member)}`, { kind: 'process', verb: 'resume', identity: identity(member) });
|
|
443
|
+
if (live) {
|
|
444
|
+
await control('resume', member);
|
|
445
|
+
if ((await scan(false)).some(row => matches(row, member) && suspended(row))) throw new Fault('PARTIAL', 'Process remains suspended.');
|
|
446
|
+
}
|
|
447
|
+
delete member.suspended;
|
|
448
|
+
this.record(job, project, String(member.pid), live ? 'resumed' : 'exited');
|
|
449
|
+
}
|
|
450
|
+
for (const id of [...(project.frozenContainers ?? [])]) {
|
|
451
|
+
this.check(job);
|
|
452
|
+
const target = targets.find(t => t.id === id && !t.shared);
|
|
453
|
+
if (!target) throw new Fault('DENIED', 'Container is outside current exclusive ownership.');
|
|
454
|
+
this.pending(job, project, `resume container ${id}`);
|
|
455
|
+
if (target.paused) await containerAction('unpause', id);
|
|
456
|
+
project.frozenContainers = project.frozenContainers.filter(value => value !== id);
|
|
457
|
+
this.record(job, project, id, 'resumed');
|
|
458
|
+
}
|
|
459
|
+
const after = visible(await scan(false), project);
|
|
460
|
+
const currentTargets = await composeTargets(project, this.store.projects());
|
|
461
|
+
const currentUnits = await serviceTargets(project, this.store.projects());
|
|
462
|
+
const currentExternal = await observeSupervisors(project, this.store.projects(), await scan(false));
|
|
463
|
+
if (after.some(row => suspended(row) || ['t', 'M', '?'].includes(row.state)) || currentTargets.some(t => !t.shared && t.paused) ||
|
|
464
|
+
currentUnits.some(unit => unit.state?.FreezerState === 'frozen') || currentExternal.some(target => !target.shared && target.state === 'Paused')) {
|
|
465
|
+
throw new Fault('PARTIAL', 'A member remains externally suspended or its execution state cannot be verified.');
|
|
466
|
+
}
|
|
467
|
+
if (after.length) await this.ready(job, project);
|
|
468
|
+
await this.restoreUnits(job, project, units, 'start', 'stoppedUnits');
|
|
469
|
+
await this.restoreSupervisors(job, project, external, 'enable', 'disabledTasks');
|
|
470
|
+
project.state = after.length || targets.some(t => t.running) || currentUnits.some(unit => unit.state?.ActiveState === 'active') || external.some(t => t.state === 'Running' || t.state === 'Paused')
|
|
471
|
+
? (project.root ? 'running' : 'adopted') : 'exited';
|
|
472
|
+
this.record(job, project, project.id, project.state);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async sleep(job, project, input) {
|
|
476
|
+
if (!project.recipe) throw new Fault('UNAVAILABLE', 'Add an approved restart recipe before sleep. Freeze preserves a workload without a recipe.');
|
|
477
|
+
if (project.state === 'sleeping') { this.record(job, project, project.id, 'sleeping'); return; }
|
|
478
|
+
const all = this.store.projects();
|
|
479
|
+
if (all.some(other => other.id !== project.id && other.recipe?.dependencies.includes(project.id) && !['sleeping', 'exited', 'registered'].includes(other.state))) {
|
|
480
|
+
throw new Fault('BUSY', 'An active project still uses this dependency.');
|
|
481
|
+
}
|
|
482
|
+
const targets = await composeTargets(project, all);
|
|
483
|
+
const units = await serviceTargets(project, all);
|
|
484
|
+
if (units.some(u => !u.shared && u.state.FreezerState === 'frozen')) throw new Fault('BUSY', 'Resume the systemd services before graceful shutdown.');
|
|
485
|
+
if (targets.some(target => !target.shared && target.running) && input.force !== true) {
|
|
486
|
+
throw new Fault('DENIED', 'Docker stop can force termination after its deadline. Review the exclusive containers and supply --force.');
|
|
487
|
+
}
|
|
488
|
+
const before = await scan();
|
|
489
|
+
await this.track(project, before);
|
|
490
|
+
const external = await observeSupervisors(project, all, before);
|
|
491
|
+
if (external.some(t => !t.shared && (t.kind === 'task' ? t.active : !['Running', 'Stopped'].includes(t.state)))) {
|
|
492
|
+
throw new Fault('BUSY', 'Resume paused services and wait for active tasks or service transitions before shutdown.');
|
|
493
|
+
}
|
|
494
|
+
const externalPids = new Set(external.map(t => t.pid));
|
|
495
|
+
const members = visible(before, project).filter(row => !externalPids.has(row.pid));
|
|
496
|
+
if (members.some(suspended)) throw new Fault('BUSY', 'Resume the project before graceful shutdown.');
|
|
497
|
+
if (windows && members.length && !project.recipe.stop && input.force !== true) throw new Fault('UNSUPPORTED', 'Windows requires an application shutdown command or explicit --force approval.');
|
|
498
|
+
project.sleepingIntent = true;
|
|
499
|
+
project.state = 'sleeping-in-progress';
|
|
500
|
+
this.pending(job, project, 'disable managed restart and begin shutdown');
|
|
501
|
+
for (const target of external.filter(t => !t.shared && t.kind === 'task' && t.enabled)) await this.changeSupervisor(job, project, 'disable', target, 'disabledTasks');
|
|
502
|
+
const activeUnits = units.filter(unit => !unit.shared && unit.state.ActiveState === 'active');
|
|
503
|
+
for (const target of activeUnits.filter(unit => unit.timer)) await this.changeUnit(job, project, 'stop', target, 'stoppedUnits');
|
|
504
|
+
const servicePids = new Set((await Promise.all(activeUnits.map(systemdPids))).flat());
|
|
505
|
+
const portKeys = new Set(members.flatMap(row => row.ports.map(port => `${port.address}:${port.port}`)));
|
|
506
|
+
if (members.length && project.recipe.stop) {
|
|
507
|
+
const env = launchEnv(project.recipe, process.env);
|
|
508
|
+
const executablePath = await executable(project.recipe.stop[0], project.cwd, env);
|
|
509
|
+
this.check(job);
|
|
510
|
+
this.pending(job, project, 'run reviewed application shutdown command', { kind: 'shutdown' });
|
|
511
|
+
await command(executablePath, project.recipe.stop.slice(1), { cwd: project.cwd, env, timeout: project.recipe.timeout });
|
|
512
|
+
this.record(job, project, project.id, 'shutdown-command-completed');
|
|
513
|
+
} else if (!windows) {
|
|
514
|
+
for (const row of [...members].reverse().filter(row => !servicePids.has(row.pid))) {
|
|
515
|
+
this.check(job);
|
|
516
|
+
this.pending(job, project, `request shutdown of process ${key(row)}`);
|
|
517
|
+
try { await control('stop', row); }
|
|
518
|
+
catch (error) { if (error.code !== 'NOT_FOUND') throw error; }
|
|
519
|
+
this.record(job, project, String(row.pid), 'shutdown-requested');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const afterTimers = await serviceTargets(project, all);
|
|
523
|
+
for (const target of afterTimers.filter(unit => !unit.shared && !unit.timer && unit.state.ActiveState === 'active')) {
|
|
524
|
+
await this.changeUnit(job, project, 'stop', target, 'stoppedUnits');
|
|
525
|
+
}
|
|
526
|
+
for (const target of external.filter(t => !t.shared && t.state === 'Running')) await this.changeSupervisor(job, project, 'stop', target, 'stoppedSupervisors');
|
|
527
|
+
let remaining = members;
|
|
528
|
+
const deadline = Date.now() + project.recipe.timeout;
|
|
529
|
+
while (remaining.length && Date.now() < deadline && !(windows && !project.recipe.stop && input.force)) {
|
|
530
|
+
this.check(job);
|
|
531
|
+
await delay(125);
|
|
532
|
+
const rows = await scan(false);
|
|
533
|
+
await this.track(project, rows);
|
|
534
|
+
remaining = visible(rows, project);
|
|
535
|
+
}
|
|
536
|
+
if (remaining.length && input.force === true) {
|
|
537
|
+
for (const row of remaining) {
|
|
538
|
+
if (!members.some(target => matches(target, row))) throw new Fault('PARTIAL', 'New processes appeared outside the reviewed force scope.');
|
|
539
|
+
this.check(job);
|
|
540
|
+
this.pending(job, project, `force stop process ${key(row)}`);
|
|
541
|
+
try { await control('force', row); }
|
|
542
|
+
catch (error) { if (error.code !== 'NOT_FOUND') throw error; }
|
|
543
|
+
this.record(job, project, String(row.pid), 'force-stop-requested');
|
|
544
|
+
}
|
|
545
|
+
await delay(200);
|
|
546
|
+
}
|
|
547
|
+
const after = await scan();
|
|
548
|
+
await this.track(project, after);
|
|
549
|
+
if (visible(after, project).length) throw new Fault('PARTIAL', 'Owned processes remain after the shutdown deadline.');
|
|
550
|
+
for (const target of targets) {
|
|
551
|
+
this.check(job);
|
|
552
|
+
if (target.shared) { this.record(job, project, `${target.project}/${target.service}`, 'shared'); continue; }
|
|
553
|
+
if (!target.running) continue;
|
|
554
|
+
this.pending(job, project, `stop container ${target.id}`);
|
|
555
|
+
await containerAction('stop', target.id, project.recipe.timeout);
|
|
556
|
+
project.stoppedContainers = [...new Set([...(project.stoppedContainers ?? []), target.id])];
|
|
557
|
+
this.record(job, project, target.id, 'stopped');
|
|
558
|
+
}
|
|
559
|
+
for (const port of portKeys) {
|
|
560
|
+
const owner = after.some(row => row.ports.some(value => `${value.address}:${value.port}` === port));
|
|
561
|
+
this.record(job, project, port, owner ? 'still-observed' : 'absent-from-authorized-scan');
|
|
562
|
+
}
|
|
563
|
+
project.state = 'sleeping';
|
|
564
|
+
this.record(job, project, project.id, 'sleeping');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async kill(job, project, input) {
|
|
568
|
+
if (input.confirm !== true || input.ids?.length !== 1 || !input.members?.length) {
|
|
569
|
+
throw new Fault('DENIED', 'Force termination requires confirmation for one project and its exact reviewed process identities.');
|
|
570
|
+
}
|
|
571
|
+
const configuration = project.recipe;
|
|
572
|
+
const all = this.store.projects();
|
|
573
|
+
const dependents = all.some(other => other.id !== project.id && other.recipe?.dependencies?.includes(project.id) &&
|
|
574
|
+
!['sleeping', 'exited', 'registered'].includes(other.state));
|
|
575
|
+
if (configuration?.containers?.length || configuration?.systemd?.length || configuration?.supervisors?.length || dependents) {
|
|
576
|
+
throw new Fault('DENIED', 'Use the service or dependency shutdown workflow for this project.');
|
|
577
|
+
}
|
|
578
|
+
const rows = await scan(false);
|
|
579
|
+
await this.track(project, rows);
|
|
580
|
+
const targets = visible(rows, project);
|
|
581
|
+
const approved = new Set(input.members.map(key));
|
|
582
|
+
if (!targets.length || targets.length !== approved.size || targets.some(row => !approved.has(key(row)))) {
|
|
583
|
+
throw new Fault('DENIED', 'Project ownership changed. Inspect the current processes and confirm again.');
|
|
584
|
+
}
|
|
585
|
+
this.check(job);
|
|
586
|
+
project.sleepingIntent = true;
|
|
587
|
+
project.state = 'terminating';
|
|
588
|
+
this.pending(job, project, 'disable managed restart before force termination');
|
|
589
|
+
for (const row of targets.sort((a, b) => Number(Boolean(matches(a, project.root))) - Number(Boolean(matches(b, project.root))))) {
|
|
590
|
+
this.check(job);
|
|
591
|
+
this.pending(job, project, `force terminate process ${key(row)}`, { kind: 'process', verb: 'force', identity: identity(row) });
|
|
592
|
+
try { await control('force', row); }
|
|
593
|
+
catch (error) { if (error.code !== 'NOT_FOUND') throw error; }
|
|
594
|
+
this.record(job, project, String(row.pid), 'force-stop-requested');
|
|
595
|
+
}
|
|
596
|
+
const deadline = Date.now() + 3000;
|
|
597
|
+
do {
|
|
598
|
+
const current = await scan(false);
|
|
599
|
+
await this.track(project, current);
|
|
600
|
+
if (!visible(current, project).length) {
|
|
601
|
+
project.state = project.recipe ? 'sleeping' : 'exited';
|
|
602
|
+
this.record(job, project, project.id, project.state);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
await delay(100);
|
|
606
|
+
} while (Date.now() < deadline);
|
|
607
|
+
throw new Fault('PARTIAL', 'Processes remain after force termination. Inspect the project before another action.');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async wake(job, project) {
|
|
611
|
+
if (project.state === 'running') { await this.ready(job, project); this.record(job, project, project.id, 'already-running'); return; }
|
|
612
|
+
if (!['sleeping', 'exited', 'partial'].includes(project.state)) throw new Fault('BUSY', 'Only a stopped project with a reviewed recipe can wake.');
|
|
613
|
+
if (visible(await scan(false), project).length) throw new Fault('BUSY', 'Verified processes already exist. Reconcile before relaunching.');
|
|
614
|
+
const targets = await composeTargets(project, this.store.projects());
|
|
615
|
+
const units = await serviceTargets(project, this.store.projects());
|
|
616
|
+
const external = await observeSupervisors(project, this.store.projects(), await scan(false));
|
|
617
|
+
await this.restoreSupervisors(job, project, external, 'start', 'stoppedSupervisors');
|
|
618
|
+
await this.restoreUnits(job, project, units.filter(u => !u.timer), 'start', 'stoppedUnits', true);
|
|
619
|
+
for (const id of [...(project.stoppedContainers ?? [])]) {
|
|
620
|
+
this.check(job);
|
|
621
|
+
const target = targets.find(t => t.id === id && !t.shared);
|
|
622
|
+
if (!target) throw new Fault('DENIED', 'Stopped container is outside current exclusive ownership.');
|
|
623
|
+
this.pending(job, project, `start container ${id}`);
|
|
624
|
+
if (!target.running) await containerAction('start', id);
|
|
625
|
+
project.stoppedContainers = project.stoppedContainers.filter(value => value !== id);
|
|
626
|
+
this.record(job, project, id, 'started');
|
|
627
|
+
}
|
|
628
|
+
await this.launch(job, project);
|
|
629
|
+
try {
|
|
630
|
+
await this.restoreUnits(job, project, units.filter(u => u.timer), 'start', 'stoppedUnits', true);
|
|
631
|
+
await this.restoreSupervisors(job, project, external, 'enable', 'disabledTasks');
|
|
632
|
+
} catch (error) { project.state = 'partial'; throw error; }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async changeSupervisor(job, project, action, target, field, remove = false) {
|
|
636
|
+
this.check(job);
|
|
637
|
+
this.pending(job, project, `${action} ${target.key}`, { kind: 'supervisor', verb: action, key: target.key, field, remove,
|
|
638
|
+
...(target.process ? { identity: identity(target.process) } : {}), ...(target.pid ? { pid: target.pid } : {}) });
|
|
639
|
+
await supervisorAction(action, target, project);
|
|
640
|
+
const previous = project[field] ?? [];
|
|
641
|
+
project[field] = remove ? previous.filter(key => key !== target.key) : [...new Set([...previous, target.key])];
|
|
642
|
+
if (action === 'pause') {
|
|
643
|
+
project.pausedSupervisorIdentities ??= {};
|
|
644
|
+
project.pausedSupervisorIdentities[target.key] = target.process ? identity(target.process) : { pid: target.pid };
|
|
645
|
+
}
|
|
646
|
+
if (action === 'resume' && project.pausedSupervisorIdentities) delete project.pausedSupervisorIdentities[target.key];
|
|
647
|
+
this.record(job, project, target.name, action);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async pauseSupervisors(job, project, targets) {
|
|
651
|
+
for (const target of targets.filter(t => !t.shared && t.kind === 'task' && t.enabled)) await this.changeSupervisor(job, project, 'disable', target, 'disabledTasks');
|
|
652
|
+
for (const target of targets.filter(t => !t.shared && t.state === 'Running')) await this.changeSupervisor(job, project, 'pause', target, 'pausedSupervisors');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async restoreSupervisors(job, project, targets, action, field) {
|
|
656
|
+
for (const key of [...(project[field] ?? [])]) {
|
|
657
|
+
const target = targets.find(t => t.key === key && !t.shared);
|
|
658
|
+
if (!target) throw new Fault('DENIED', 'The previously changed resource is outside current exclusive ownership.');
|
|
659
|
+
const pausedIdentity = project.pausedSupervisorIdentities?.[key];
|
|
660
|
+
if (action === 'resume' && pausedIdentity && (target.pid !== pausedIdentity.pid ||
|
|
661
|
+
(pausedIdentity.birth && !matches(target.process, pausedIdentity)))) {
|
|
662
|
+
throw new Fault('DENIED', 'The previously paused supervisor process was replaced.');
|
|
663
|
+
}
|
|
664
|
+
if (action === 'enable' ? target.enabled : target.state === 'Running') {
|
|
665
|
+
project[field] = project[field].filter(value => value !== key);
|
|
666
|
+
if (action === 'resume' && project.pausedSupervisorIdentities) delete project.pausedSupervisorIdentities[key];
|
|
667
|
+
this.record(job, project, target.name, 'already-restored');
|
|
668
|
+
} else await this.changeSupervisor(job, project, action, target, field, true);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async changeUnit(job, project, action, target, field, remove = false) {
|
|
673
|
+
this.check(job);
|
|
674
|
+
this.pending(job, project, `${action} systemd user unit ${target.unit}`);
|
|
675
|
+
await serviceAction(action, target, project.recipe?.timeout ?? 10000);
|
|
676
|
+
const previous = project[field] ?? [];
|
|
677
|
+
project[field] = remove ? previous.filter(name => name !== target.unit) : [...new Set([...previous, target.unit])];
|
|
678
|
+
this.record(job, project, target.unit, ({ start: 'started', stop: 'stopped', freeze: 'frozen', thaw: 'resumed' })[action]);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async restoreUnits(job, project, units, action, field, subset = false) {
|
|
682
|
+
for (const unit of [...(project[field] ?? [])]) {
|
|
683
|
+
const target = units.find(t => t.unit === unit && !t.shared);
|
|
684
|
+
if (!target && subset) continue;
|
|
685
|
+
if (!target) throw new Fault('DENIED', 'Previously changed systemd unit is outside current exclusive ownership.');
|
|
686
|
+
const already = action === 'thaw' ? target.state.FreezerState === 'running' : target.state.ActiveState === 'active';
|
|
687
|
+
if (already) {
|
|
688
|
+
project[field] = project[field].filter(name => name !== unit);
|
|
689
|
+
this.record(job, project, unit, 'already-active');
|
|
690
|
+
} else await this.changeUnit(job, project, action, target, field, true);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async capture(job, project, input) {
|
|
695
|
+
const rows = await scan(false);
|
|
696
|
+
await this.track(project, rows);
|
|
697
|
+
const root = alive(rows, project.root);
|
|
698
|
+
const targets = await composeTargets(project, this.store.projects());
|
|
699
|
+
const backend = input.backend ?? 'criu';
|
|
700
|
+
if (project.recipe?.systemd?.length || project.recipe?.supervisors?.length) throw new Fault('UNSUPPORTED', 'A process checkpoint cannot capture registered external supervisors.');
|
|
701
|
+
if (!['criu', 'docker'].includes(backend)) throw new Fault('USAGE', 'Choose the criu or docker checkpoint backend.');
|
|
702
|
+
if (backend === 'criu') {
|
|
703
|
+
if (project.excluded.length || visible(rows, project).some(row => row.state === 'T' || row.state === 't')) {
|
|
704
|
+
throw new Fault('UNSUPPORTED', 'Checkpoint requires an exclusive running tree with no excluded or suspended members.');
|
|
705
|
+
}
|
|
706
|
+
if (root) {
|
|
707
|
+
const descendants = new Set([root.pid]);
|
|
708
|
+
let count;
|
|
709
|
+
do { count = descendants.size; rows.filter(row => descendants.has(row.parent)).forEach(row => descendants.add(row.pid)); }
|
|
710
|
+
while (descendants.size > count);
|
|
711
|
+
if (visible(rows, project).some(row => !descendants.has(row.pid)) || [...descendants].some(pid => !visible(rows, project).some(row => row.pid === pid))) {
|
|
712
|
+
throw new Fault('UNSUPPORTED', 'CRIU tree scope differs from the verified owned process set.');
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const verify = async target => {
|
|
717
|
+
if (!alive(await scan(false), target)) throw new Fault('NOT_FOUND', 'The checkpoint root identity changed.');
|
|
718
|
+
const namespace = {};
|
|
719
|
+
for (const name of ['pid', 'mnt', 'net', 'user']) {
|
|
720
|
+
const [own, member] = await Promise.all([readlink(`/proc/self/ns/${name}`), readlink(`/proc/${target.pid}/ns/${name}`)]).catch(() => []);
|
|
721
|
+
if (!own || own !== member) throw new Fault('UNSUPPORTED', `CRIU capture requires the same ${name} namespace.`);
|
|
722
|
+
namespace[name] = own;
|
|
723
|
+
}
|
|
724
|
+
return namespace;
|
|
725
|
+
};
|
|
726
|
+
const id = await checkpoint(this.state, project, root, backend, targets, id => {
|
|
727
|
+
this.check(job);
|
|
728
|
+
this.pending(job, project, `checkpoint ${id}`);
|
|
729
|
+
}, verify);
|
|
730
|
+
this.record(job, project, id, 'captured');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async restore(job, project, input) {
|
|
734
|
+
if (project.recipe?.systemd?.length || project.recipe?.supervisors?.length) throw new Fault('UNSUPPORTED', 'Restore requires the original process and container scope.');
|
|
735
|
+
if (visible(await scan(false), project).length) throw new Fault('BUSY', 'Stop the active workload before restoring a checkpoint.');
|
|
736
|
+
if (project.recipe?.ready && await listening(project.recipe.ready)) throw new Fault('BUSY', 'The original readiness port is already active.');
|
|
737
|
+
const targets = await composeTargets(project, this.store.projects());
|
|
738
|
+
const pid = await restore(this.state, project, input.checkpoint, targets, (id, effect) => {
|
|
739
|
+
this.check(job);
|
|
740
|
+
project.state = 'restoring';
|
|
741
|
+
this.pending(job, project, `restore checkpoint ${id}`, { kind: 'restore', ...effect });
|
|
742
|
+
});
|
|
743
|
+
if (pid) {
|
|
744
|
+
const rows = await scan(false);
|
|
745
|
+
const root = rows.find(row => row.pid === pid && !stopped(row));
|
|
746
|
+
if (!root) throw new Fault('PARTIAL', 'Restored root identity could not be observed.');
|
|
747
|
+
project.root = identity(root);
|
|
748
|
+
project.members = [{ ...identity(root), source: 'launched' }];
|
|
749
|
+
project.cgroup = undefined;
|
|
750
|
+
await this.track(project, rows);
|
|
751
|
+
this.pending(job, project, 'verify restored workload', { ...project.uncertainEffect, identity: project.root });
|
|
752
|
+
await this.ready(job, project);
|
|
753
|
+
}
|
|
754
|
+
project.state = 'running';
|
|
755
|
+
this.record(job, project, input.checkpoint, 'restored');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async reconcile(job, ids) {
|
|
759
|
+
const rows = await scan(false);
|
|
760
|
+
const projects = ids?.length ? ids.map(id => this.store.project(id)) : this.store.projects();
|
|
761
|
+
job.op.projects = projects.map(p => p.id);
|
|
762
|
+
for (const project of projects) {
|
|
763
|
+
this.check(job);
|
|
764
|
+
await this.track(project, rows);
|
|
765
|
+
const live = visible(rows, project);
|
|
766
|
+
const units = await serviceTargets(project, this.store.projects());
|
|
767
|
+
const containers = await composeTargets(project, this.store.projects());
|
|
768
|
+
const external = await observeSupervisors(project, this.store.projects(), rows);
|
|
769
|
+
const effect = project.uncertainEffect;
|
|
770
|
+
if (effect?.kind === 'shutdown' && !live.length) delete project.uncertainEffect;
|
|
771
|
+
if (effect?.kind === 'launch') {
|
|
772
|
+
if (live.length) await this.ready(job, project);
|
|
773
|
+
delete project.uncertainEffect;
|
|
774
|
+
}
|
|
775
|
+
if (effect?.kind === 'restore') {
|
|
776
|
+
if (effect.boot !== await bootId()) delete project.uncertainEffect;
|
|
777
|
+
else if (effect.backend === 'criu' && effect.identity && live.some(row => matches(row, effect.identity))) {
|
|
778
|
+
await this.ready(job, project);
|
|
779
|
+
delete project.uncertainEffect;
|
|
780
|
+
} else if (effect.backend === 'docker' && containers.some(target =>
|
|
781
|
+
!target.shared && target.id === effect.container && target.running && !target.paused)) delete project.uncertainEffect;
|
|
782
|
+
if (project.uncertainEffect) {
|
|
783
|
+
project.state = 'partial';
|
|
784
|
+
this.record(job, project, project.id, 'failed', new Fault('PARTIAL', 'The interrupted restore could not verify its target. Review the operation and checkpoint before restarting.'));
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (effect?.kind === 'process') {
|
|
789
|
+
const row = alive(rows, effect.identity);
|
|
790
|
+
const member = project.members.find(item => matches(item, effect.identity));
|
|
791
|
+
if (!row || !member || !suspended(row)) {
|
|
792
|
+
if (member) delete member.suspended;
|
|
793
|
+
delete project.uncertainEffect;
|
|
794
|
+
} else if (effect.verb === 'freeze') this.record(job, project, String(row.pid), 'unconfirmed-suspension');
|
|
795
|
+
else if (member.suspended) delete project.uncertainEffect;
|
|
796
|
+
}
|
|
797
|
+
if (effect?.kind === 'supervisor') {
|
|
798
|
+
const target = external.find(item => item.key === effect.key && !item.shared);
|
|
799
|
+
if (target && (effect.verb === 'enable' ? target.enabled : effect.verb === 'resume' ? target.state === 'Running' && target.pid === effect.pid : false)) {
|
|
800
|
+
project[effect.field] = (project[effect.field] ?? []).filter(value => value !== effect.key);
|
|
801
|
+
delete project.uncertainEffect;
|
|
802
|
+
}
|
|
803
|
+
if (target && (effect.verb === 'disable' ? target.enabled && !target.active :
|
|
804
|
+
effect.verb === 'pause' || effect.verb === 'stop' ? target.state === 'Running' && target.pid === effect.pid :
|
|
805
|
+
effect.verb === 'start' ? target.state === 'Stopped' : effect.verb === 'resume' && target.state === 'Paused' && target.pid === effect.pid)) {
|
|
806
|
+
delete project.uncertainEffect;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const servicePids = new Set((await Promise.all(units.map(systemdPids))).flat());
|
|
810
|
+
if (project.uncertainEffect || live.some(row => suspended(row) || ['t', 'M', '?'].includes(row.state)) || project.frozenContainers?.length || project.frozenUnits?.length || project.pausedSupervisors?.length) {
|
|
811
|
+
const isFrozen = live.filter(row => !servicePids.has(row.pid)).every(suspended) && containers.every(c => c.shared || !c.running || c.paused) &&
|
|
812
|
+
units.every(u => u.shared || u.state.ActiveState !== 'active' || (u.state.FreezerState === 'frozen' && !u.timer));
|
|
813
|
+
project.state = !project.uncertainEffect && project.state === 'frozen' && isFrozen ? 'frozen' : 'partial';
|
|
814
|
+
} else if (live.length) project.state = project.root ? 'running' : 'adopted';
|
|
815
|
+
else if (runningService(containers, units)) project.state = 'running';
|
|
816
|
+
else if (project.state !== 'sleeping') project.state = 'exited';
|
|
817
|
+
this.record(job, project, project.id, project.state);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|