ultravisor-beacon 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Ultravisor Beacon Client
3
+ *
4
+ * A lightweight worker node that connects to an Ultravisor server,
5
+ * registers its capabilities, polls for work, executes tasks locally,
6
+ * and reports results back to the orchestrator.
7
+ *
8
+ * Capabilities are provided by pluggable CapabilityProviders loaded
9
+ * via the ProviderRegistry. The beacon advertises the aggregate set
10
+ * of capabilities from all loaded providers.
11
+ *
12
+ * Communication is HTTP-based (transport-agnostic design means this
13
+ * can be swapped for WebSocket, MQTT, etc. in the future).
14
+ */
15
+
16
+ const libHTTP = require('http');
17
+
18
+ const libBeaconExecutor = require('./Ultravisor-Beacon-Executor.cjs');
19
+
20
+ class UltravisorBeaconClient
21
+ {
22
+ constructor(pConfig)
23
+ {
24
+ this._Config = Object.assign({
25
+ ServerURL: 'http://localhost:54321',
26
+ Name: 'beacon-worker',
27
+ Password: '',
28
+ Capabilities: ['Shell'],
29
+ MaxConcurrent: 1,
30
+ PollIntervalMs: 5000,
31
+ HeartbeatIntervalMs: 30000,
32
+ StagingPath: process.cwd(),
33
+ Tags: {}
34
+ }, pConfig || {});
35
+
36
+ this._BeaconID = null;
37
+ this._PollInterval = null;
38
+ this._HeartbeatInterval = null;
39
+ this._Running = false;
40
+ this._ActiveWorkItems = 0;
41
+ this._SessionCookie = null;
42
+ this._Authenticating = false;
43
+
44
+ this._Executor = new libBeaconExecutor({
45
+ StagingPath: this._Config.StagingPath
46
+ });
47
+
48
+ // Load capability providers
49
+ this._loadProviders();
50
+ }
51
+
52
+ // ================================================================
53
+ // Provider Loading
54
+ // ================================================================
55
+
56
+ _loadProviders()
57
+ {
58
+ let tmpProviders = this._Config.Providers;
59
+
60
+ if (!tmpProviders)
61
+ {
62
+ // Backward compatibility: convert Capabilities array to Provider descriptors
63
+ let tmpCapabilities = this._Config.Capabilities || ['Shell'];
64
+ tmpProviders = tmpCapabilities.map(function (pCap)
65
+ {
66
+ return { Source: pCap, Config: {} };
67
+ });
68
+ }
69
+
70
+ let tmpCount = this._Executor.providerRegistry.loadProviders(tmpProviders);
71
+ console.log(`[Beacon] Loaded ${tmpCount} capability provider(s).`);
72
+ }
73
+
74
+ // ================================================================
75
+ // Lifecycle
76
+ // ================================================================
77
+
78
+ /**
79
+ * Start the Beacon client: initialize providers, register, then begin polling.
80
+ */
81
+ start(fCallback)
82
+ {
83
+ console.log(`[Beacon] Starting "${this._Config.Name}"...`);
84
+ console.log(`[Beacon] Server: ${this._Config.ServerURL}`);
85
+
86
+ // Initialize all providers before registering
87
+ this._Executor.providerRegistry.initializeAll((pInitError) =>
88
+ {
89
+ if (pInitError)
90
+ {
91
+ console.error(`[Beacon] Provider initialization failed: ${pInitError.message}`);
92
+ return fCallback(pInitError);
93
+ }
94
+
95
+ let tmpCapabilities = this._Executor.providerRegistry.getCapabilities();
96
+ console.log(`[Beacon] Capabilities: ${tmpCapabilities.join(', ')}`);
97
+
98
+ // Authenticate before registering
99
+ this._authenticate((pAuthError) =>
100
+ {
101
+ if (pAuthError)
102
+ {
103
+ console.error(`[Beacon] Authentication failed: ${pAuthError.message}`);
104
+ return fCallback(pAuthError);
105
+ }
106
+
107
+ console.log(`[Beacon] Authenticated successfully.`);
108
+
109
+ this._register((pError, pBeacon) =>
110
+ {
111
+ if (pError)
112
+ {
113
+ console.error(`[Beacon] Registration failed: ${pError.message}`);
114
+ return fCallback(pError);
115
+ }
116
+
117
+ this._BeaconID = pBeacon.BeaconID;
118
+ this._Running = true;
119
+
120
+ console.log(`[Beacon] Registered as ${this._BeaconID}`);
121
+
122
+ // Start polling for work
123
+ this._PollInterval = setInterval(() =>
124
+ {
125
+ this._poll();
126
+ }, this._Config.PollIntervalMs);
127
+
128
+ // Start heartbeat
129
+ this._HeartbeatInterval = setInterval(() =>
130
+ {
131
+ this._heartbeat();
132
+ }, this._Config.HeartbeatIntervalMs);
133
+
134
+ // Do an immediate poll
135
+ this._poll();
136
+
137
+ return fCallback(null, pBeacon);
138
+ });
139
+ });
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Stop the Beacon client: stop polling, shutdown providers, deregister.
145
+ */
146
+ stop(fCallback)
147
+ {
148
+ console.log(`[Beacon] Stopping...`);
149
+ this._Running = false;
150
+
151
+ if (this._PollInterval)
152
+ {
153
+ clearInterval(this._PollInterval);
154
+ this._PollInterval = null;
155
+ }
156
+
157
+ if (this._HeartbeatInterval)
158
+ {
159
+ clearInterval(this._HeartbeatInterval);
160
+ this._HeartbeatInterval = null;
161
+ }
162
+
163
+ // Clean up affinity staging directories
164
+ this._Executor.cleanupAffinityDirs();
165
+
166
+ // Shutdown providers
167
+ this._Executor.providerRegistry.shutdownAll((pShutdownError) =>
168
+ {
169
+ if (pShutdownError)
170
+ {
171
+ console.warn(`[Beacon] Provider shutdown warning: ${pShutdownError.message}`);
172
+ }
173
+
174
+ if (this._BeaconID)
175
+ {
176
+ this._deregister((pError) =>
177
+ {
178
+ if (pError)
179
+ {
180
+ console.warn(`[Beacon] Deregistration warning: ${pError.message}`);
181
+ }
182
+ console.log(`[Beacon] Stopped.`);
183
+ if (fCallback) return fCallback(null);
184
+ });
185
+ }
186
+ else
187
+ {
188
+ console.log(`[Beacon] Stopped.`);
189
+ if (fCallback) return fCallback(null);
190
+ }
191
+ });
192
+ }
193
+
194
+ // ================================================================
195
+ // Authentication
196
+ // ================================================================
197
+
198
+ _authenticate(fCallback)
199
+ {
200
+ let tmpBody = {
201
+ UserName: this._Config.Name,
202
+ Password: this._Config.Password || ''
203
+ };
204
+
205
+ let tmpBodyString = JSON.stringify(tmpBody);
206
+ let tmpParsedURL = new URL(this._Config.ServerURL);
207
+ let tmpOptions = {
208
+ hostname: tmpParsedURL.hostname,
209
+ port: tmpParsedURL.port || 80,
210
+ path: '/1.0/Authenticate',
211
+ method: 'POST',
212
+ headers: {
213
+ 'Content-Type': 'application/json',
214
+ 'Content-Length': Buffer.byteLength(tmpBodyString)
215
+ }
216
+ };
217
+
218
+ let tmpReq = libHTTP.request(tmpOptions, (pResponse) =>
219
+ {
220
+ let tmpData = '';
221
+ pResponse.on('data', (pChunk) => { tmpData += pChunk; });
222
+ pResponse.on('end', () =>
223
+ {
224
+ if (pResponse.statusCode >= 400)
225
+ {
226
+ return fCallback(new Error(`Authentication failed with HTTP ${pResponse.statusCode}`));
227
+ }
228
+
229
+ // Extract session cookie from Set-Cookie headers
230
+ let tmpSetCookieHeaders = pResponse.headers['set-cookie'];
231
+ if (tmpSetCookieHeaders && tmpSetCookieHeaders.length > 0)
232
+ {
233
+ // Take the name=value portion (before the first semicolon)
234
+ let tmpCookieParts = tmpSetCookieHeaders[0].split(';');
235
+ this._SessionCookie = tmpCookieParts[0].trim();
236
+ console.log(`[Beacon] Session cookie acquired.`);
237
+ }
238
+
239
+ try
240
+ {
241
+ let tmpParsed = JSON.parse(tmpData);
242
+ return fCallback(null, tmpParsed);
243
+ }
244
+ catch (pParseError)
245
+ {
246
+ return fCallback(new Error(`Invalid JSON in auth response: ${tmpData.substring(0, 200)}`));
247
+ }
248
+ });
249
+ });
250
+
251
+ tmpReq.on('error', (pError) =>
252
+ {
253
+ return fCallback(pError);
254
+ });
255
+
256
+ tmpReq.write(tmpBodyString);
257
+ tmpReq.end();
258
+ }
259
+
260
+ // ================================================================
261
+ // Reconnection
262
+ // ================================================================
263
+
264
+ _reconnect()
265
+ {
266
+ if (this._Authenticating)
267
+ {
268
+ return;
269
+ }
270
+ this._Authenticating = true;
271
+
272
+ // Clear existing intervals
273
+ if (this._PollInterval)
274
+ {
275
+ clearInterval(this._PollInterval);
276
+ this._PollInterval = null;
277
+ }
278
+ if (this._HeartbeatInterval)
279
+ {
280
+ clearInterval(this._HeartbeatInterval);
281
+ this._HeartbeatInterval = null;
282
+ }
283
+
284
+ this._SessionCookie = null;
285
+
286
+ console.log(`[Beacon] Reconnecting — re-authenticating...`);
287
+
288
+ this._authenticate((pAuthError) =>
289
+ {
290
+ if (pAuthError)
291
+ {
292
+ console.error(`[Beacon] Re-authentication failed: ${pAuthError.message}`);
293
+ this._Authenticating = false;
294
+ setTimeout(() => { this._reconnect(); }, 10000);
295
+ return;
296
+ }
297
+
298
+ console.log(`[Beacon] Re-authenticated, re-registering...`);
299
+
300
+ this._register((pRegError, pBeacon) =>
301
+ {
302
+ if (pRegError)
303
+ {
304
+ console.error(`[Beacon] Re-registration failed: ${pRegError.message}`);
305
+ this._Authenticating = false;
306
+ setTimeout(() => { this._reconnect(); }, 10000);
307
+ return;
308
+ }
309
+
310
+ this._BeaconID = pBeacon.BeaconID;
311
+ this._Authenticating = false;
312
+
313
+ console.log(`[Beacon] Reconnected as ${this._BeaconID}`);
314
+
315
+ // Restart polling
316
+ this._PollInterval = setInterval(() =>
317
+ {
318
+ this._poll();
319
+ }, this._Config.PollIntervalMs);
320
+
321
+ // Restart heartbeat
322
+ this._HeartbeatInterval = setInterval(() =>
323
+ {
324
+ this._heartbeat();
325
+ }, this._Config.HeartbeatIntervalMs);
326
+
327
+ // Immediate poll
328
+ this._poll();
329
+ });
330
+ });
331
+ }
332
+
333
+ // ================================================================
334
+ // Registration
335
+ // ================================================================
336
+
337
+ _register(fCallback)
338
+ {
339
+ let tmpBody = {
340
+ Name: this._Config.Name,
341
+ Capabilities: this._Executor.providerRegistry.getCapabilities(),
342
+ MaxConcurrent: this._Config.MaxConcurrent,
343
+ Tags: this._Config.Tags
344
+ };
345
+
346
+ this._httpRequest('POST', '/Beacon/Register', tmpBody, fCallback);
347
+ }
348
+
349
+ _deregister(fCallback)
350
+ {
351
+ this._httpRequest('DELETE', `/Beacon/${this._BeaconID}`, null, fCallback);
352
+ }
353
+
354
+ // ================================================================
355
+ // Polling
356
+ // ================================================================
357
+
358
+ _poll()
359
+ {
360
+ if (!this._Running || !this._BeaconID)
361
+ {
362
+ return;
363
+ }
364
+
365
+ if (this._ActiveWorkItems >= this._Config.MaxConcurrent)
366
+ {
367
+ return;
368
+ }
369
+
370
+ this._httpRequest('POST', '/Beacon/Work/Poll', { BeaconID: this._BeaconID },
371
+ (pError, pResponse) =>
372
+ {
373
+ if (pError)
374
+ {
375
+ // Silent on poll errors — just retry next interval
376
+ return;
377
+ }
378
+
379
+ if (!pResponse || !pResponse.WorkItem)
380
+ {
381
+ // No work available
382
+ return;
383
+ }
384
+
385
+ // Execute the work item
386
+ this._executeWorkItem(pResponse.WorkItem);
387
+ });
388
+ }
389
+
390
+ // ================================================================
391
+ // Work Execution
392
+ // ================================================================
393
+
394
+ _executeWorkItem(pWorkItem)
395
+ {
396
+ this._ActiveWorkItems++;
397
+ console.log(`[Beacon] Executing work item [${pWorkItem.WorkItemHash}] (${pWorkItem.Capability}/${pWorkItem.Action})`);
398
+
399
+ // Create a progress callback that sends updates to the server
400
+ let tmpWorkItemHash = pWorkItem.WorkItemHash;
401
+ let fReportProgress = (pProgressData) =>
402
+ {
403
+ this._reportProgress(tmpWorkItemHash, pProgressData);
404
+ };
405
+
406
+ this._Executor.execute(pWorkItem, (pError, pResult) =>
407
+ {
408
+ this._ActiveWorkItems--;
409
+
410
+ if (pError)
411
+ {
412
+ console.error(`[Beacon] Execution error for [${pWorkItem.WorkItemHash}]: ${pError.message}`);
413
+ this._reportError(pWorkItem.WorkItemHash, pError.message, []);
414
+ return;
415
+ }
416
+
417
+ // Check if the result indicates an error (non-zero exit code)
418
+ let tmpOutputs = pResult.Outputs || {};
419
+ if (tmpOutputs.ExitCode && tmpOutputs.ExitCode !== 0)
420
+ {
421
+ console.warn(`[Beacon] Work item [${pWorkItem.WorkItemHash}] completed with exit code ${tmpOutputs.ExitCode}`);
422
+ }
423
+ else
424
+ {
425
+ console.log(`[Beacon] Work item [${pWorkItem.WorkItemHash}] completed successfully.`);
426
+ }
427
+
428
+ this._reportComplete(pWorkItem.WorkItemHash, tmpOutputs, pResult.Log || []);
429
+ }, fReportProgress);
430
+ }
431
+
432
+ // ================================================================
433
+ // Reporting
434
+ // ================================================================
435
+
436
+ _reportComplete(pWorkItemHash, pOutputs, pLog)
437
+ {
438
+ this._httpRequest('POST', `/Beacon/Work/${pWorkItemHash}/Complete`,
439
+ { Outputs: pOutputs, Log: pLog },
440
+ (pError) =>
441
+ {
442
+ if (pError)
443
+ {
444
+ console.error(`[Beacon] Failed to report completion for [${pWorkItemHash}]: ${pError.message}`);
445
+ }
446
+ });
447
+ }
448
+
449
+ _reportError(pWorkItemHash, pErrorMessage, pLog)
450
+ {
451
+ this._httpRequest('POST', `/Beacon/Work/${pWorkItemHash}/Error`,
452
+ { ErrorMessage: pErrorMessage, Log: pLog },
453
+ (pError) =>
454
+ {
455
+ if (pError)
456
+ {
457
+ console.error(`[Beacon] Failed to report error for [${pWorkItemHash}]: ${pError.message}`);
458
+ }
459
+ });
460
+ }
461
+
462
+ _reportProgress(pWorkItemHash, pProgressData)
463
+ {
464
+ if (!pProgressData || !this._Running)
465
+ {
466
+ return;
467
+ }
468
+
469
+ this._httpRequest('POST', `/Beacon/Work/${pWorkItemHash}/Progress`,
470
+ pProgressData,
471
+ (pError) =>
472
+ {
473
+ if (pError)
474
+ {
475
+ // Fire-and-forget — log but don't affect execution
476
+ console.warn(`[Beacon] Failed to report progress for [${pWorkItemHash}]: ${pError.message}`);
477
+ }
478
+ });
479
+ }
480
+
481
+ // ================================================================
482
+ // Heartbeat
483
+ // ================================================================
484
+
485
+ _heartbeat()
486
+ {
487
+ if (!this._Running || !this._BeaconID)
488
+ {
489
+ return;
490
+ }
491
+
492
+ this._httpRequest('POST', `/Beacon/${this._BeaconID}/Heartbeat`, {},
493
+ (pError) =>
494
+ {
495
+ if (pError)
496
+ {
497
+ console.warn(`[Beacon] Heartbeat failed: ${pError.message}`);
498
+ }
499
+ });
500
+ }
501
+
502
+ // ================================================================
503
+ // HTTP Transport
504
+ // ================================================================
505
+
506
+ _httpRequest(pMethod, pPath, pBody, fCallback)
507
+ {
508
+ let tmpParsedURL = new URL(this._Config.ServerURL);
509
+ let tmpOptions = {
510
+ hostname: tmpParsedURL.hostname,
511
+ port: tmpParsedURL.port || 80,
512
+ path: pPath,
513
+ method: pMethod,
514
+ headers: {
515
+ 'Content-Type': 'application/json'
516
+ }
517
+ };
518
+
519
+ // Attach session cookie if available
520
+ if (this._SessionCookie)
521
+ {
522
+ tmpOptions.headers['Cookie'] = this._SessionCookie;
523
+ }
524
+
525
+ let tmpReq = libHTTP.request(tmpOptions, (pResponse) =>
526
+ {
527
+ let tmpData = '';
528
+ pResponse.on('data', (pChunk) => { tmpData += pChunk; });
529
+ pResponse.on('end', () =>
530
+ {
531
+ // Detect 401 and trigger reconnection
532
+ if (pResponse.statusCode === 401)
533
+ {
534
+ this._reconnect();
535
+ return fCallback(new Error('Unauthorized — reconnecting'));
536
+ }
537
+
538
+ try
539
+ {
540
+ let tmpParsed = JSON.parse(tmpData);
541
+ if (pResponse.statusCode >= 400)
542
+ {
543
+ return fCallback(new Error(tmpParsed.Error || `HTTP ${pResponse.statusCode}`));
544
+ }
545
+ return fCallback(null, tmpParsed);
546
+ }
547
+ catch (pParseError)
548
+ {
549
+ return fCallback(new Error(`Invalid JSON response: ${tmpData.substring(0, 200)}`));
550
+ }
551
+ });
552
+ });
553
+
554
+ tmpReq.on('error', (pError) =>
555
+ {
556
+ return fCallback(pError);
557
+ });
558
+
559
+ if (pBody && (pMethod === 'POST' || pMethod === 'PUT'))
560
+ {
561
+ tmpReq.write(JSON.stringify(pBody));
562
+ }
563
+
564
+ tmpReq.end();
565
+ }
566
+ }
567
+
568
+ module.exports = UltravisorBeaconClient;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Ultravisor Beacon Connectivity — HTTP Transport
3
+ *
4
+ * Thin configuration layer for HTTP-based beacon connectivity.
5
+ * The actual HTTP transport is handled by the thin client
6
+ * (Ultravisor-Beacon-Client.cjs). This class exists as the
7
+ * abstraction point for swapping in alternative transports
8
+ * (e.g. WebSocket) in the future.
9
+ *
10
+ * For the HTTP transport, the thin client's polling, heartbeat,
11
+ * authentication, and reconnection logic are used as-is.
12
+ */
13
+
14
+ class UltravisorBeaconConnectivityHTTP
15
+ {
16
+ constructor(pOptions)
17
+ {
18
+ this._Options = Object.assign({
19
+ ServerURL: 'http://localhost:54321',
20
+ Password: '',
21
+ PollIntervalMs: 5000,
22
+ HeartbeatIntervalMs: 30000
23
+ }, pOptions || {});
24
+ }
25
+
26
+ /**
27
+ * Get the transport configuration subset needed by the thin client.
28
+ *
29
+ * @returns {object} Config suitable for BeaconClient constructor
30
+ */
31
+ getTransportConfig()
32
+ {
33
+ return {
34
+ ServerURL: this._Options.ServerURL,
35
+ Password: this._Options.Password,
36
+ PollIntervalMs: this._Options.PollIntervalMs,
37
+ HeartbeatIntervalMs: this._Options.HeartbeatIntervalMs
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Get the transport type identifier.
43
+ *
44
+ * @returns {string}
45
+ */
46
+ getTransportType()
47
+ {
48
+ return 'HTTP';
49
+ }
50
+ }
51
+
52
+ module.exports = UltravisorBeaconConnectivityHTTP;