@pixlcore/xyrun 1.0.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.
Files changed (4) hide show
  1. package/LICENSE.md +28 -0
  2. package/README.md +74 -0
  3. package/main.js +979 -0
  4. package/package.json +29 -0
package/LICENSE.md ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2019 - 2026 PixlCore LLC & CONTRIBUTORS.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Overview
2
+
3
+ **xyOps Remote Job Runner (xyRun)** is a companion to the [xyOps](https://xyops.io) workflow automation and server monitoring platform. It is a wrapper for running remote jobs inside Docker containers, or over a remote SSH connection.
4
+
5
+ The idea is that when a job is running "remotely" (i.e. not a direct child process of [xySat](https://github.com/pixlcore/xysat)) then we cannot monitor system resources for the job. Also, input and output files simply do not work in these cases (because xySat expects them to be on the local filesystem where it is running). xyRun handles all these complexites for you by sitting "in between" your job and xySat. xyRun should run *inside* the container or on the far end of the SSH connection, where your job process is running.
6
+
7
+ To use xyRun in a xyOps Event Plugin, make sure you set the Plugin's `runner` property to `true`. This hint tells xyOps (and ultimately xySat) that the job is running remotely out if its reach, and it should not perform the usual process and network monitoring, and file management. Those duties get delegated to xyRun.
8
+
9
+ ## Features
10
+
11
+ - Handles monitoring procesess, network connections, CPU and memory usage of remote jobs, and passing those metrics back to xyOps.
12
+ - Handles input files by creating a temporary directory for you job and pre-downloading all files from the xyOps master server.
13
+ - Handles output files by intercepting the `files` message and uploading them directly to the xyOps master server.
14
+
15
+ # Installation
16
+
17
+ xyRun requires both Node.js LTS and NPM.
18
+
19
+ ## Docker
20
+
21
+ In your Dockerfile, preinstall Node.js LTS if needed (assuming Ubuntu / Debian base):
22
+
23
+ ```
24
+ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash
25
+ RUN apt-get update && apt-get install -y nodejs
26
+ ```
27
+
28
+ And preinstall xyRun like this:
29
+
30
+ ```
31
+ RUN npm install -g @pixlcore/xyrun
32
+ ```
33
+
34
+ Then wrap your `CMD` with a `xyrun` prefix like this:
35
+
36
+ ```
37
+ CMD xyrun node /path/to/your-script.js
38
+ ```
39
+
40
+ xyRun will directly launch whatever is passed to it on the CLI, including arguments.
41
+
42
+ ## Other
43
+
44
+ For other uses (i.e. SSH) install the NPM module globally on the target machine where the remote job will be running:
45
+
46
+ ```sh
47
+ npm install -g @pixlcore/xyrun
48
+ ```
49
+
50
+ Then wrap your remote command with a `xyrun` prefix:
51
+
52
+ ```sh
53
+ ssh user@target xyrun node /path/to/your-script.js
54
+ ```
55
+
56
+ # Development
57
+
58
+ You can install the source code by using [Git](https://en.wikipedia.org/wiki/Git) ([Node.js](https://nodejs.org/) is also required):
59
+
60
+ ```sh
61
+ git clone https://github.com/pixlcore/xyrun.git
62
+ cd xyrun
63
+ npm install
64
+ ```
65
+
66
+ You can then pass it mock job data by issuing a pipe command such as:
67
+
68
+ ```sh
69
+ echo '{"xy":1, "runner":true}' | node main.js node your-script-here.js
70
+ ```
71
+
72
+ # License
73
+
74
+ See [LICENSE.md](LICENSE.md) in this repository.
package/main.js ADDED
@@ -0,0 +1,979 @@
1
+ #!/usr/bin/env node
2
+
3
+ // xyRun - xyOps Remote Job Runner - Main entry point
4
+ // Copyright (c) 2019 - 2025 PixlCore LLC
5
+ // BSD 3-Clause License -- see LICENSE.md
6
+
7
+ const Path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const cp = require('child_process');
11
+ const JSONStream = require('pixl-json-stream');
12
+ const PixlRequest = require('pixl-request');
13
+ const Tools = require('pixl-tools');
14
+ const pkg = require('./package.json');
15
+ const noop = function() {};
16
+
17
+ const app = {
18
+
19
+ activeJobs: {},
20
+ kids: {},
21
+ procCache: {},
22
+ connCache: {},
23
+
24
+ async run() {
25
+ // read job from stdin
26
+ const chunks = [];
27
+ for await (const chunk of process.stdin) { chunks.push(chunk); }
28
+ this.job = JSON.parse( chunks.join('') );
29
+
30
+ // create a http request instance for various tasks
31
+ this.request = new PixlRequest( "xyOps Job Runner v" + pkg.version );
32
+ this.request.setTimeout( 300 * 1000 );
33
+ this.request.setFollow( 5 );
34
+ this.request.setAutoError( true );
35
+ this.request.setKeepAlive( true );
36
+
37
+ // prime this for repeated calls
38
+ this.numCPUs = os.cpus().length;
39
+
40
+ // sniff platform
41
+ this.platform = {};
42
+ switch (process.platform) {
43
+ case 'linux': this.platform.linux = true; break;
44
+ case 'darwin': this.platform.darwin = true; break;
45
+ case 'freebsd': case 'openbsd': case 'netbsd': this.platform.bsd = true; break;
46
+ case 'win32': this.platform.windows = true; break;
47
+ }
48
+
49
+ if (this.platform.linux) {
50
+ // pre-calc location of some binaries
51
+ this.psBin = Tools.findBinSync('ps');
52
+ this.ssBin = Tools.findBinSync('ss');
53
+ } // linux
54
+
55
+ if (this.platform.darwin) {
56
+ // pre-calc location of some binaries
57
+ this.psBin = Tools.findBinSync('ps');
58
+ } // darwin
59
+
60
+ // launch child process
61
+ this.prepLaunchJob();
62
+
63
+ // start ticker
64
+ this.timer = setInterval( this.tick.bind(this), 1000 );
65
+ },
66
+
67
+ prepLaunchJob() {
68
+ // setup temp dir for job, and download any files passed to us
69
+ var self = this;
70
+ var job = this.job;
71
+
72
+ // no input files? skip prep, and no temp dir needed
73
+ if (!job.input || !job.input.files || !job.input.files.length) {
74
+ this.launchJob();
75
+ return;
76
+ }
77
+
78
+ async.series([
79
+ function(callback) {
80
+ // create temp dir for job, full access
81
+ Tools.mkdirp( job.cwd, { mode: 0o777 }, callback );
82
+ },
83
+ function(callback) {
84
+ // download input files to job temp dir if we were given any
85
+ async.eachSeries( job.input.files,
86
+ function(file, callback) {
87
+ var dest_file = Path.join( job.cwd, file.filename );
88
+ var url = job.base_url + '/' + file.path;
89
+ var opts = Tools.mergeHashes( job.socket_opts || {}, {
90
+ download: dest_file
91
+ });
92
+
93
+ console.log( `Downloading file: ${dest_file} (${Tools.getTextFromBytes(file.size)})` );
94
+
95
+ self.request.get( url, opts, function(err, resp, data, perf) {
96
+ if (err) {
97
+ return callback( new Error("Failed to download job file: " + file.filename + ": " + (err.message || err)) );
98
+ }
99
+ callback();
100
+ } ); // request.get
101
+ },
102
+ callback
103
+ ); // eachSeries
104
+ }
105
+ ],
106
+ function(err) {
107
+ if (err) {
108
+ // something went wrong
109
+ job.pid = 0;
110
+ job.code = 1;
111
+ job.description = "Runner: " + err;
112
+ console.error(job.description);
113
+ self.activeJobs[ job.id ] = job;
114
+ self.finishJob( job );
115
+ return;
116
+ }
117
+
118
+ // launch job for real
119
+ self.launchJob();
120
+ });
121
+ },
122
+
123
+ launchJob() {
124
+ // launch job on this server!
125
+ var self = this;
126
+ var job = this.job;
127
+ var child = null;
128
+ var worker = {};
129
+
130
+ var child_args = process.argv.slice(2);
131
+ var child_cmd = child_args.shift();
132
+
133
+ if (!child_cmd) {
134
+ // no command!
135
+ job.pid = 0;
136
+ job.code = 1;
137
+ job.description = "Runner: No command specified.";
138
+ console.error(job.description);
139
+ self.activeJobs[ job.id ] = job;
140
+ self.finishJob();
141
+ return;
142
+ }
143
+ if (!job.runner) {
144
+ // not in runner mode
145
+ job.pid = 0;
146
+ job.code = 1;
147
+ job.description = "Job not launched in runner mode (Set runner flag in event plugin).";
148
+ console.error(job.description);
149
+ self.activeJobs[ job.id ] = job;
150
+ self.finishJob();
151
+ return;
152
+ }
153
+
154
+ console.log( "Running command: " + child_cmd, child_args );
155
+
156
+ // setup environment for child
157
+ var child_opts = {
158
+ env: Object.assign( {}, process.env )
159
+ };
160
+
161
+ // get uid / gid info for child env vars
162
+ if (!this.platform.windows) {
163
+ child_opts.uid = job.uid || process.getuid();
164
+ child_opts.gid = process.getgid();
165
+
166
+ var user_info = Tools.getpwnam( child_opts.uid, true );
167
+ if (user_info) {
168
+ child_opts.uid = user_info.uid;
169
+ child_opts.gid = user_info.gid;
170
+ child_opts.env.USER = child_opts.env.USERNAME = user_info.username;
171
+ child_opts.env.HOME = user_info.dir;
172
+ child_opts.env.SHELL = user_info.shell;
173
+ }
174
+ else if (child_opts.uid != process.getuid()) {
175
+ // user not found
176
+ job.pid = 0;
177
+ job.code = 1;
178
+ job.description = "Plugin Error: User does not exist: " + child_opts.uid;
179
+ console.error(job.description);
180
+ this.activeJobs[ job.id ] = job;
181
+ this.finishJob();
182
+ return;
183
+ }
184
+
185
+ if (job.gid) {
186
+ var grp_info = Tools.getgrnam( job.gid, true );
187
+ if (grp_info) {
188
+ child_opts.gid = grp_info.gid;
189
+ }
190
+ else {
191
+ // gid not found
192
+ job.pid = 0;
193
+ job.code = 1;
194
+ job.description = "Plugin Error: Group does not exist: " + job.gid;
195
+ console.error(job.description);
196
+ this.activeJobs[ job.id ] = job;
197
+ this.finishJob();
198
+ return;
199
+ }
200
+ }
201
+
202
+ child_opts.uid = parseInt( child_opts.uid );
203
+ child_opts.gid = parseInt( child_opts.gid );
204
+ }
205
+
206
+ // windows additions
207
+ if (this.platform.windows) {
208
+ child_opts.windowsHide = true;
209
+ }
210
+
211
+ // attach streams
212
+ child_opts.stdio = ['pipe', 'pipe', 'inherit'];
213
+
214
+ // spawn child
215
+ try {
216
+ child = cp.spawn( child_cmd, child_args, child_opts );
217
+ if (!child || !child.pid || !child.stdin || !child.stdout) {
218
+ throw new Error("Child process failed to spawn (Check executable location and permissions?)");
219
+ }
220
+ }
221
+ catch (err) {
222
+ if (child) child.on('error', function() {}); // prevent crash
223
+ job.pid = 0;
224
+ job.code = 1;
225
+ job.description = "Runner: Child spawn error: " + child_cmd + ": " + Tools.getErrorDescription(err);
226
+ console.error(job.description);
227
+ this.activeJobs[ job.id ] = job;
228
+ this.finishJob();
229
+ return;
230
+ }
231
+ job.pid = child.pid || 0;
232
+
233
+ // connect json stream to child's stdio
234
+ // order reversed deliberately (out, in)
235
+ var stream = new JSONStream( child.stdout, child.stdin );
236
+ stream.recordRegExp = /^\s*\{.+\}\s*$/;
237
+ stream.preserveWhitespace = true;
238
+ stream.maxLineLength = 1024 * 1024;
239
+ stream.EOL = "\n";
240
+
241
+ worker.pid = job.pid;
242
+ worker.child = child;
243
+ worker.stream = stream;
244
+ this.worker = worker;
245
+
246
+ stream.on('json', function(data) {
247
+ // received data from child
248
+ if (!self.handleChildResponse(job, worker, data)) {
249
+ // unrecognized json, emit as raw text
250
+ stream.emit('text', JSON.stringify(data) + "\n");
251
+ }
252
+ } );
253
+
254
+ stream.on('text', function(line) {
255
+ // received non-json text from child, log it
256
+ if (self.platform.windows) line = line.replace(/\r$/, '');
257
+ process.stdout.write(line);
258
+ } );
259
+
260
+ stream.on('error', function(err, text) {
261
+ // Probably a JSON parse error (child emitting garbage)
262
+ console.error( "Child stream error: Job ID " + job.id + ": PID " + job.pid + ": " + err );
263
+ if (text) process.stdout.write(text);
264
+ } );
265
+
266
+ child.on('error', function (err) {
267
+ // child error
268
+ job.code = 1;
269
+ job.description = "Runner: Child process error: " + Tools.getErrorDescription(err);
270
+ worker.child_exited = true;
271
+ console.error(job.description);
272
+ self.finishJob();
273
+ } );
274
+
275
+ child.on('close', function (code, signal) {
276
+ // child exited
277
+ if (code || signal) {
278
+ console.error( "Child exited with code: " + (code || signal) );
279
+ }
280
+ worker.child_exited = true;
281
+ self.finishJob();
282
+ } ); // on exit
283
+
284
+ // pass job to child
285
+ worker.child.stdin.write( JSON.stringify(job) + "\n" );
286
+
287
+ // we're done writing to the child -- don't hold its stdin open
288
+ worker.child.stdin.end();
289
+
290
+ // track job in our own hash
291
+ this.activeJobs[ job.id ] = job;
292
+ this.kids[ job.pid ] = worker;
293
+ },
294
+
295
+ handleChildResponse(job, worker, data) {
296
+ // intercept child responses, handle files and completion ourselves
297
+ if (!data.xy) return false;
298
+ Tools.mergeHashInto( job, data );
299
+
300
+ // intecept complete/code, as we need to handle it here after file uploads
301
+ if ('code' in data) delete data.code;
302
+ if ('description' in data) delete data.description;
303
+ if ('complete' in data) delete data.complete;
304
+
305
+ if (data.files) {
306
+ // remove from job update for parent, as we'll handle this here
307
+ delete data.files;
308
+ }
309
+ else if (data.push && data.push.files) {
310
+ // carefully extract files out of push update
311
+ if (!job.files) job.files = [];
312
+ job.files = job.files.concat( data.push.files );
313
+ delete data.push.files;
314
+ if (!Tools.numKeys(data.push)) delete data.push;
315
+ }
316
+
317
+ // passthrough other data if present
318
+ var updates = Tools.copyHashRemoveKeys(data, { xy:1 });
319
+ return !Tools.numKeys(updates);
320
+ },
321
+
322
+ abortJob() {
323
+ // send kill signal if child is active
324
+ console.error(`Caught abort signal, shutting down`);
325
+
326
+ var job = this.job;
327
+ var worker = this.worker;
328
+
329
+ if (!job || !worker) process.exit(0);
330
+
331
+ if (worker.child) {
332
+ // kill process(es) or not, depending on abort policy
333
+ if (job.kill === 'none') {
334
+ // kill none, just unref and finish
335
+ worker.child.unref();
336
+ this.shutdown();
337
+ return;
338
+ }
339
+
340
+ worker.kill_timer = setTimeout( function() {
341
+ // child didn't die, kill with prejudice
342
+ if ((job.kill === 'all') && job.procs && Tools.firstKey(job.procs)) {
343
+ // sig-kill ALL job processes
344
+ var pids = Object.keys(job.procs);
345
+ console.error( "Children did not exit, killing harder: " + pids.join(', '));
346
+ pids.forEach( function(pid) {
347
+ try { process.kill(pid, 'SIGKILL'); }
348
+ catch(e) {;}
349
+ } );
350
+ }
351
+ else {
352
+ // sig-kill parent only
353
+ console.error( "Child did not exit, killing harder: " + job.pid);
354
+ worker.child.kill('SIGKILL');
355
+ }
356
+ }, 10000 );
357
+
358
+ // try killing nicely first
359
+ if ((job.kill === 'all') && job.procs && Tools.firstKey(job.procs)) {
360
+ // sig-term ALL job processes
361
+ var pids = Object.keys(job.procs);
362
+ console.log( "Killing all job processes: " + pids.join(', '));
363
+ pids.forEach( function(pid) {
364
+ try { process.kill(pid, 'SIGTERM'); }
365
+ catch(e) {;}
366
+ } );
367
+ }
368
+ else {
369
+ // sig-term parent only
370
+ console.log( "Killing job process: " + job.pid);
371
+ worker.child.kill('SIGTERM');
372
+ }
373
+ }
374
+ else process.exit(0);
375
+ },
376
+
377
+ finishJob() {
378
+ // upload files
379
+ var self = this;
380
+ var job = this.job;
381
+ var worker = this.worker;
382
+
383
+ if (!job || !worker) return this.shutdown(); // sanity
384
+
385
+ if (worker.kill_timer) {
386
+ clearTimeout( worker.kill_timer );
387
+ delete worker.kill_timer;
388
+ }
389
+
390
+ delete this.job;
391
+ delete this.worker;
392
+
393
+ this.activeJobs = {};
394
+ this.kids = {};
395
+
396
+ this.prepUploadJobFiles(job, function(err) {
397
+ if (err) {
398
+ job.code = err.code || 'upload';
399
+ job.description = "Runner: " + (err.message || err);
400
+ }
401
+
402
+ // did we upload files? if so, send the metadata along now
403
+ if (job.files && job.files.length) {
404
+ console.log( JSON.stringify({ xy: 1, files: job.files }) );
405
+ }
406
+
407
+ // now we're done done with job -- send final update
408
+ console.log( JSON.stringify({ xy: 1, complete: true, code: job.code || 0, description: job.description || "" }) );
409
+
410
+ // delete temp dir
411
+ if (job.cwd) Tools.rimraf( job.cwd, noop );
412
+
413
+ // all done
414
+ self.shutdown();
415
+ });
416
+ },
417
+
418
+ prepUploadJobFiles(job, callback) {
419
+ // glob all file requests to resolve them to individual files, then upload
420
+ var self = this;
421
+ var to_upload = [];
422
+ if (!job.files || !job.files.length || !Tools.isaArray(job.files)) return callback();
423
+
424
+ async.eachSeries( job.files,
425
+ function(file, callback) {
426
+ if (typeof(file) == 'string') {
427
+ file = { path: file };
428
+ }
429
+ else if (Array.isArray(file)) {
430
+ if (file.length == 3) file = { path: file[0], filename: file[1], delete: file[2] };
431
+ else if (file.length == 2) file = { path: file[0], filename: file[1] };
432
+ else file = { path: file[0] };
433
+ }
434
+
435
+ if (!file.path) return; // sanity
436
+
437
+ // prepend job cwd if path is not absolute
438
+ if (!Path.isAbsolute(file.path)) file.path = Path.join(job.cwd, file.path);
439
+
440
+ if (file.filename) {
441
+ // if user specified a custom filename, then do not perform a glob
442
+ to_upload.push(file);
443
+ process.nextTick(callback);
444
+ }
445
+ else Tools.glob( file.path, function(err, files) {
446
+ if (!files) files = [];
447
+ files.forEach( function(path) {
448
+ to_upload.push({ path: path, delete: !!file.delete });
449
+ } );
450
+ callback();
451
+ } );
452
+ },
453
+ function() {
454
+ job.files = to_upload;
455
+ self.uploadJobFiles(job, callback);
456
+ }
457
+ ); // eachSeries
458
+ },
459
+
460
+ uploadJobFiles(job, callback) {
461
+ // upload all job files (from user) if applicable
462
+ var self = this;
463
+ var final_files = [];
464
+ var server_id = job.server;
465
+ if (!job.files || !job.files.length || !Tools.isaArray(job.files)) return callback();
466
+
467
+ async.eachSeries( job.files,
468
+ function(file, callback) {
469
+ var filename = Path.basename(file.filename || file.path).replace(/[^\w\-\+\.\,\s\(\)\[\]\{\}\'\"\!\&\^\%\$\#\@\*\?\~]+/g, '_');
470
+ console.log( "Uploading file: " + filename );
471
+
472
+ var url = job.base_url + '/api/app/upload_job_file';
473
+ var opts = Tools.mergeHashes( job.socket_opts || {}, {
474
+ "files": {
475
+ file1: [file.path, filename]
476
+ },
477
+ "data": {
478
+ id: job.id,
479
+ server: job.server,
480
+ auth: job.auth_token
481
+ }
482
+ });
483
+
484
+ self.request.post( url, opts, function(err, resp, data, perf) {
485
+ if (err) {
486
+ return callback( new Error("Failed to upload job file: " + filename + ": " + (err.message || err)) );
487
+ }
488
+
489
+ var json = null;
490
+ try { json = JSON.parse( data.toString() ); }
491
+ catch (err) { return callback(err); }
492
+
493
+ if (json.code && json.description) {
494
+ return callback( new Error("Failed to upload job file: " + filename + ": " + json.description) );
495
+ }
496
+
497
+ // save file metadata
498
+ final_files.push({
499
+ id: file.id || Tools.generateShortID('f'),
500
+ date: Tools.timeNow(true),
501
+ filename: filename,
502
+ path: json.key,
503
+ size: json.size,
504
+ server: server_id,
505
+ job: job.id
506
+ });
507
+
508
+ if (file.delete) fs.unlink(file.path, callback);
509
+ else return callback();
510
+ }); // request.post
511
+ },
512
+ function(err) {
513
+ // replace job.files with storage keys
514
+ if (err) console.error(err);
515
+ else job.files = final_files;
516
+ callback(err);
517
+ }
518
+ );
519
+ },
520
+
521
+ tick() {
522
+ // called every second
523
+ // monitor job processes
524
+ this.jobTick();
525
+ },
526
+
527
+ jobTick() {
528
+ // called every second
529
+ var self = this;
530
+ if (!this.job) return;
531
+
532
+ if (this.jobTickInProgress) return; // no steppy on toesy
533
+ this.jobTickInProgress = true;
534
+
535
+ // scan all processes on machine
536
+ // si.processes( function(data) {
537
+ this.getProcsCached( function(data) {
538
+ if (!self.job) {
539
+ self.jobTickInProgress = false;
540
+ return;
541
+ }
542
+
543
+ // cleanup and convert to hash of pids
544
+ var pids = {};
545
+ data.list.forEach( function(proc) {
546
+ // proc.started = (new Date( proc.started )).getTime() / 1000;
547
+ // proc.memRss = proc.memRss * 1024;
548
+ // proc.memVsz = proc.memVsz * 1024;
549
+ pids[ proc.pid ] = proc;
550
+ } );
551
+
552
+ for (var job_id in self.activeJobs) {
553
+ var job = self.activeJobs[job_id];
554
+ self.measureJobResources(job, pids);
555
+ }
556
+
557
+ async.parallel(
558
+ [
559
+ self.measureJobDiskIO.bind(self),
560
+ self.measureJobNetworkIO.bind(self)
561
+ ],
562
+ function() {
563
+ if (!self.job) {
564
+ self.jobTickInProgress = false;
565
+ return;
566
+ }
567
+
568
+ // update job data
569
+ console.log( JSON.stringify({ xy: 1, procs: job.procs, conns: job.conns, cpu: job.cpu, mem: job.mem }) );
570
+
571
+ self.jobTickInProgress = false;
572
+ }
573
+ ); // async.parallel
574
+ } ); // si.processes
575
+ },
576
+
577
+ measureJobResources(job, pids) {
578
+ // scan process list for all processes that are descendents of job pid
579
+ delete job.procs;
580
+
581
+ if (pids[ job.pid ]) {
582
+ // add all procs into job
583
+ job.procs = {};
584
+ job.procs[ job.pid ] = pids[ job.pid ];
585
+
586
+ var info = pids[ job.pid ];
587
+ var cpu = info.cpu;
588
+ var mem = info.memRss;
589
+
590
+ // also consider children of the child (up to 100 generations deep)
591
+ var levels = 0;
592
+ var family = {};
593
+ family[ job.pid ] = 1;
594
+
595
+ while (Tools.numKeys(family) && (++levels <= 100)) {
596
+ for (var fpid in family) {
597
+ for (var cpid in pids) {
598
+ if (pids[ cpid ].parentPid == fpid) {
599
+ family[ cpid ] = 1;
600
+ cpu += pids[ cpid ].cpu;
601
+ mem += pids[ cpid ].memRss;
602
+ job.procs[ cpid ] = pids[ cpid ];
603
+ } // matched
604
+ } // cpid loop
605
+ delete family[fpid];
606
+ } // fpid loop
607
+ } // while
608
+
609
+ if (job.cpu) {
610
+ if (cpu < job.cpu.min) job.cpu.min = cpu;
611
+ if (cpu > job.cpu.max) job.cpu.max = cpu;
612
+ job.cpu.total += cpu;
613
+ job.cpu.count++;
614
+ job.cpu.current = cpu;
615
+ }
616
+ else {
617
+ job.cpu = { min: cpu, max: cpu, total: cpu, count: 1, current: cpu };
618
+ }
619
+
620
+ if (job.mem) {
621
+ if (mem < job.mem.min) job.mem.min = mem;
622
+ if (mem > job.mem.max) job.mem.max = mem;
623
+ job.mem.total += mem;
624
+ job.mem.count++;
625
+ job.mem.current = mem;
626
+ }
627
+ else {
628
+ job.mem = { min: mem, max: mem, total: mem, count: 1, current: mem };
629
+ }
630
+ } // matched job with pid
631
+ },
632
+
633
+ measureJobDiskIO(callback) {
634
+ // use linux /proc/PID/io to glean disk r/w per sec per job proc
635
+ var self = this;
636
+ var procs = [];
637
+
638
+ // zero everything out for non-linux
639
+ for (var job_id in this.activeJobs) {
640
+ var job = this.activeJobs[job_id];
641
+ if (job.procs) {
642
+ for (var pid in job.procs) { job.procs[pid].disk = 0; }
643
+ }
644
+ }
645
+
646
+ // this trick is linux only
647
+ if (process.platform != 'linux') return process.nextTick( callback );
648
+
649
+ // get array of all active job procs
650
+ for (var job_id in this.activeJobs) {
651
+ var job = this.activeJobs[job_id];
652
+ if (job.procs) procs = procs.concat( Object.values(job.procs) );
653
+ }
654
+
655
+ // parallelize this just a smidge, as it can be a lot of reads
656
+ async.eachLimit( procs, 4,
657
+ function(proc, callback) {
658
+ fs.readFile( '/proc/' + proc.pid + '/io', 'utf8', function(err, text) {
659
+ // if (!text) text = "rchar: " + Math.floor( Tools.timeNow(true) * 1024 ); // sample data (for testing)
660
+ if (!text) text = "";
661
+
662
+ // parse into key/value pairs
663
+ var params = {};
664
+ text.replace( /(\w+)\:\s*(\d+)/g, function(m_all, key, value) {
665
+ params[key] = parseInt(value);
666
+ return m_all;
667
+ } );
668
+
669
+ // take disk w + r per proc
670
+ proc.disk = (params.rchar || 0) + (params.wchar || 0);
671
+ // proc.disk = (params.read_bytes || 0) + (params.write_bytes || 0);
672
+
673
+ callback();
674
+ } );
675
+ },
676
+ callback
677
+ ); // async.eachLimit
678
+ },
679
+
680
+ measureJobNetworkIO(callback) {
681
+ // use linux `ss` utility to glean network r/w per sec per job proc
682
+ var self = this;
683
+
684
+ // zero everything out for non-linux
685
+ for (var job_id in this.activeJobs) {
686
+ var job = this.activeJobs[job_id];
687
+ if (job.procs) {
688
+ for (var pid in job.procs) {
689
+ job.procs[pid].conns = 0;
690
+ job.procs[pid].net = 0;
691
+ }
692
+ }
693
+ }
694
+
695
+ // this trick is linux only
696
+ if ((process.platform != 'linux') || !this.ssBin) return process.nextTick( callback );
697
+
698
+ cp.exec( this.ssBin + ' -nutipaO', { timeout: 1000, maxBuffer: 1024 * 1024 * 32 }, function(err, stdout, stderr) {
699
+ if (err) {
700
+ console.error("Failed to launch ss: " + err);
701
+ return callback();
702
+ }
703
+
704
+ var now = Tools.timeNow(true);
705
+ var lines = stdout.split(/\n/);
706
+ var ids = {};
707
+
708
+ lines.forEach( function(line) {
709
+ if (line.match(/^(tcp|tcp4|tcp6|udp|udp4|udp6)\s+(\w+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+.+pid\=(\d+)/)) {
710
+ var type = RegExp.$1, state = RegExp.$2, local_addr = RegExp.$5, remote_addr = RegExp.$6, pid = RegExp.$7;
711
+
712
+ // clean up some stuff
713
+ pid = parseInt(pid);
714
+ if (state == "ESTAB") state = 'ESTABLISHED';
715
+ if (state == "UNCONN") state = 'UNCONNECTED';
716
+
717
+ // generate socket "id" key using combo of local + remote
718
+ var id = local_addr + '|' + remote_addr;
719
+
720
+ if (!self.connCache[id]) self.connCache[id] = { bytes: 0, delta: 0, started: now };
721
+ var conn = self.connCache[id];
722
+
723
+ conn.type = type;
724
+ conn.state = state;
725
+ conn.local_addr = local_addr;
726
+ conn.remote_addr = remote_addr;
727
+ conn.pid = pid;
728
+
729
+ var bytes = 0;
730
+ if (line.match(/\bbytes_acked\:(\d+)/)) bytes += parseInt( RegExp.$1 ); // tx
731
+ if (line.match(/\bbytes_received\:(\d+)/)) bytes += parseInt( RegExp.$1 ); // rx
732
+
733
+ conn.delta = bytes - conn.bytes;
734
+ conn.bytes = bytes;
735
+
736
+ ids[id] = 1;
737
+ }
738
+ } ); // foreach line
739
+
740
+ // delete sweep for removed conns
741
+ for (var id in self.connCache) {
742
+ if (!(id in ids)) delete self.connCache[id];
743
+ }
744
+
745
+ // join up conns with jobs and job procs
746
+ Object.values(self.activeJobs).forEach( function(job) {
747
+ if (!job.procs) return;
748
+
749
+ job.conns = [];
750
+ for (var id in self.connCache) {
751
+ var conn = self.connCache[id];
752
+ if (conn.pid in job.procs) {
753
+ job.conns.push(conn);
754
+ job.procs[conn.pid].conns++;
755
+ job.procs[conn.pid].net += conn.delta;
756
+ }
757
+ }
758
+
759
+ }); // foreach job
760
+
761
+ callback();
762
+ } ); // cp.exec
763
+ },
764
+
765
+ getProcsCached(callback) {
766
+ // get process information, cached with a dynamic rolling debounce
767
+ // (TTL is based on the previous cache miss elapsed time)
768
+ // (designed to throttle on slower machines, or with thousands of processes)
769
+ var self = this;
770
+ var now = Tools.timeNow();
771
+ var cache = this.procCache;
772
+
773
+ if (cache.data) {
774
+ if (now < cache.expires) {
775
+ // still fresh
776
+ return callback( Tools.copyHash(cache.data, true) );
777
+ }
778
+ }
779
+
780
+ this.getProcsFast( function(data) {
781
+ // save cache data
782
+ cache.data = data;
783
+ cache.date = Tools.timeNow();
784
+ cache.elapsed = cache.date - now;
785
+ cache.expires = cache.date + (cache.elapsed * 5);
786
+ callback( Tools.copyHash(cache.data, true) );
787
+ } );
788
+ },
789
+
790
+ getProcsFast(callback) {
791
+ // get process information fast
792
+ var self = this;
793
+ var now = Tools.timeNow(true);
794
+
795
+ if (this.platform.windows) {
796
+ return si.processes( function(data) {
797
+ data.list.forEach( function(proc) {
798
+ // convert data to our native format
799
+ try {
800
+ proc.started = Math.floor( (new Date(proc.started)).getTime() / 1000 );
801
+ proc.age = now - proc.started;
802
+ }
803
+ catch (e) { proc.started = proc.age = 0; }
804
+
805
+ // some commands are quoted
806
+ proc.command = proc.command.replace(/^\"(.+?)\"/, '$1');
807
+
808
+ // cleanup state
809
+ proc.state = Tools.ucfirst( proc.state || 'unknown' );
810
+
811
+ // memory readings are in kilobytes
812
+ proc.memRss *= 1024;
813
+ proc.memVsz *= 1024;
814
+
815
+ // delete redundant props
816
+ delete proc.path;
817
+ delete proc.params;
818
+ });
819
+ callback(data);
820
+ } );
821
+ } // windows
822
+
823
+ var info = { list: [] };
824
+ var ps_args = [];
825
+ var ps_opts = {
826
+ env: Object.assign( {}, process.env ),
827
+ maxBuffer: 1024 * 1024 * 100,
828
+ timeout: 30000
829
+ };
830
+ const colMap = {
831
+ ppid: 'parentPid',
832
+ rss: 'memRss',
833
+ vsz: 'memVsz',
834
+ tt: 'tty',
835
+ thcnt: 'threads',
836
+ pri: 'priority',
837
+ ni: 'nice',
838
+ s: 'state',
839
+ stat: 'state',
840
+ elapsed: 'age',
841
+ cls: 'class',
842
+ gid: 'group',
843
+ args: 'command'
844
+ };
845
+ const stateMap = {
846
+ I: 'Idle',
847
+ S: 'Sleeping',
848
+ D: 'Sleeping',
849
+ U: 'Sleeping',
850
+ R: 'Running',
851
+ Z: 'Zombie',
852
+ T: 'Stopped',
853
+ t: 'Stopped',
854
+ W: 'Paged',
855
+ X: 'Dead'
856
+ };
857
+ const classMap = {
858
+ TS: 'Other',
859
+ FF: 'FIFO',
860
+ RR: 'RR',
861
+ B: 'Batch',
862
+ ISO: 'ISO',
863
+ IDL: 'Idle',
864
+ DLN: 'Deadline'
865
+ };
866
+ const filterMap = {
867
+ pid: parseInt,
868
+ parentPid: parseInt,
869
+ priority: parseInt,
870
+ nice: parseInt,
871
+ threads: parseInt,
872
+ time: parseInt,
873
+
874
+ // cpu: parseFloat,
875
+ mem: parseFloat,
876
+
877
+ cpu: function(value) {
878
+ // divide by CPU count for real value
879
+ return parseFloat(value) / self.numCPUs;
880
+ },
881
+
882
+ age: function(value) {
883
+ if (value.match(/^\d+$/)) return parseInt(value);
884
+ if (value.match(/^(\d+)\-(\d+)\:(\d+)\:(\d+)$/)) {
885
+ // DD-HH:MI:SS
886
+ var [ dd, hh, mi, ss ] = [ RegExp.$1, RegExp.$2, RegExp.$3, RegExp.$4 ];
887
+ return ( (parseInt(dd) * 86400) + (parseInt(hh) * 3600) + (parseInt(mi) * 60) + parseInt(ss) );
888
+ }
889
+ if (value.match(/^(\d+)\:(\d+)\:(\d+)$/)) {
890
+ // HH:MI:SS
891
+ var [ hh, mi, ss ] = [ RegExp.$1, RegExp.$2, RegExp.$3 ];
892
+ return ( (parseInt(hh) * 3600) + (parseInt(mi) * 60) + parseInt(ss) );
893
+ }
894
+ if (value.match(/^(\d+)\:(\d+)$/)) {
895
+ // MI:SS
896
+ var [ mi, ss ] = [ RegExp.$1, RegExp.$2 ];
897
+ return ( (parseInt(mi) * 60) + parseInt(ss) );
898
+ }
899
+ return 0;
900
+ },
901
+ memRss: function(value) {
902
+ return parseInt(value) * 1024;
903
+ },
904
+ memVsz: function(value) {
905
+ return parseInt(value) * 1024;
906
+ },
907
+ state: function(value) {
908
+ return stateMap[value.substring(0, 1)] || 'Unknown';
909
+ },
910
+ class: function(value) {
911
+ return classMap[value] || 'Unknown';
912
+ },
913
+ group: function(value) {
914
+ if (value.match(/^\d+$/)) {
915
+ var group = Tools.getgrnam( value, true ); // cached in ram
916
+ if (group && group.name) return group.name;
917
+ }
918
+ return value;
919
+ }
920
+ };
921
+
922
+ if (this.platform.linux) {
923
+ // PID PPID USER %CPU RSS ELAPSED S PRI NI VSZ TT %MEM CLS GROUP THCNT TIME COMMAND
924
+ ps_args = ['-eo', 'pid,ppid,user,%cpu,rss,etimes,state,pri,nice,vsz,tty,%mem,class,group,thcount,times,args'];
925
+ ps_opts.env.LC_ALL = 'C';
926
+ }
927
+ else if (this.platform.darwin) {
928
+ // PID PPID %CPU %MEM PRI VSZ RSS NI ELAPSED STAT TTY USER GID ARGS
929
+ ps_args = ['-axro', 'pid,ppid,%cpu,%mem,pri,vsz,rss,nice,etime,state,tty,user,group,args'];
930
+ }
931
+
932
+ cp.execFile( this.psBin, ps_args, ps_opts, function(err, stdout, stderr) {
933
+ if (err) return callback(info);
934
+
935
+ var lines = stdout.trim().split(/\n/);
936
+ var headers = lines.shift().trim().split(/\s+/).map( function(key) { return key.trim().toLowerCase().replace(/\W+/g, ''); } );
937
+
938
+ lines.forEach( function(line) {
939
+ var cols = line.trim().split(/\s+/);
940
+ if (cols.length > headers.length) {
941
+ var extras = cols.splice(headers.length);
942
+ cols[ headers.length - 1 ] += ' ' + extras.join(' ');
943
+ }
944
+ var proc = {};
945
+
946
+ headers.forEach( function(key, idx) {
947
+ key = colMap[key] || key;
948
+ proc[key] = filterMap[key] ? filterMap[key](cols[idx]) : cols[idx];
949
+ } );
950
+
951
+ proc.started = Math.max(0, now - (proc.age || 0));
952
+
953
+ // state bookkeeping
954
+ var state = proc.state.toLowerCase();
955
+ info[ state ] = (info[ state ] || 0) + 1;
956
+ info.all = (info.all || 0) + 1;
957
+
958
+ // filter out ps itself
959
+ if ((proc.parentPid == process.pid) && (proc.command.startsWith(self.psBin))) return;
960
+
961
+ info.list.push(proc);
962
+ } );
963
+
964
+ callback(info);
965
+ }); // cp.execFile
966
+ },
967
+
968
+ shutdown() {
969
+ // all done
970
+ if (this.timer) clearInterval(this.timer);
971
+ delete this.timer;
972
+ }
973
+ };
974
+
975
+ process.on('SIGTERM', app.abortJob.bind(app) );
976
+ process.on('SIGINT', app.abortJob.bind(app) );
977
+ process.on('SIGHUP', app.abortJob.bind(app) );
978
+
979
+ app.run();
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@pixlcore/xyrun",
3
+ "version": "1.0.0",
4
+ "description": "Remote job runner script for xyOps.",
5
+ "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
+ "homepage": "https://github.com/pixlcore/xyrun",
7
+ "license": "BSD-3-Clause",
8
+ "bin": {
9
+ "xyrun": "./main.js"
10
+ },
11
+ "main": "main.js",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/pixlcore/xyrun"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/pixlcore/xyrun/issues"
18
+ },
19
+ "keywords": [
20
+ "xyops",
21
+ "xyrun"
22
+ ],
23
+ "dependencies": {
24
+ "pixl-json-stream": "^1.0.7",
25
+ "pixl-request": "^2.4.1",
26
+ "pixl-tools": "^1.1.12",
27
+ "systeminformation": "5.27.7"
28
+ }
29
+ }