ultravisor 1.0.24 → 1.0.26
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/docs/_sidebar.md +1 -0
- package/docs/features/persistence-via-databeacon.md +1211 -0
- package/package.json +6 -6
- package/source/cli/Ultravisor-CLIProgram.cjs +80 -0
- package/source/config/Ultravisor-Default-Command-Configuration.cjs +9 -1
- package/source/datamodel/Ultravisor-Fleet.json +66 -0
- package/source/persistence/UltravisorPersistenceSchema.json +240 -0
- package/source/services/Ultravisor-AuthBeaconBridge.cjs +271 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +339 -151
- package/source/services/Ultravisor-Beacon-Scheduler.cjs +65 -29
- package/source/services/Ultravisor-DirectoryDistributor.cjs +280 -0
- package/source/services/Ultravisor-ExecutionManifest.cjs +99 -4
- package/source/services/Ultravisor-FleetManager.cjs +871 -0
- package/source/services/Ultravisor-ManifestStoreBridge.cjs +1134 -0
- package/source/services/Ultravisor-QueuePersistenceBridge.cjs +1336 -0
- package/source/services/persistence/Ultravisor-Beacon-FleetStore.cjs +570 -0
- package/source/web_server/Ultravisor-API-Server.cjs +1185 -90
- package/test/fleetstore-smoke.js +152 -0
- package/webinterface/package.json +1 -0
- package/webinterface/source/Pict-Application-Ultravisor.js +59 -2
- package/webinterface/source/providers/PictRouter-Ultravisor-Configuration.json +12 -0
- package/webinterface/source/views/PictView-Ultravisor-Fleet.js +489 -0
- package/webinterface/source/views/PictView-Ultravisor-Login.js +74 -0
- package/webinterface/source/views/PictView-Ultravisor-TopBar.js +26 -0
- package/webinterface/source/views/PictView-Ultravisor-UserManagement.js +159 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor-ManifestStoreBridge
|
|
3
|
+
*
|
|
4
|
+
* Single front door for "where do operation execution manifests live?".
|
|
5
|
+
* Two backends, picked at call time:
|
|
6
|
+
*
|
|
7
|
+
* 1. The optional ultravisor-manifest-beacon (capability =
|
|
8
|
+
* ManifestStore). When connected, every persistence write
|
|
9
|
+
* dispatches to the beacon and reads come back from it.
|
|
10
|
+
*
|
|
11
|
+
* 2. The in-process UltravisorExecutionManifest service. Used as
|
|
12
|
+
* the fallback whenever the beacon isn't connected — preserves
|
|
13
|
+
* the existing on-disk JSON-blob persistence behavior.
|
|
14
|
+
*
|
|
15
|
+
* Symmetric with Ultravisor-QueuePersistenceBridge: same beacon-or-
|
|
16
|
+
* local pattern, same Promise-based API, same fail-open posture.
|
|
17
|
+
*
|
|
18
|
+
* Loop prevention: the coordinator's _isMetaCapability gate skips
|
|
19
|
+
* persistence-recording for ManifestStore dispatches, mirroring how
|
|
20
|
+
* QueuePersistence is skipped. MS_* dispatches are themselves work
|
|
21
|
+
* items routed through the queue, but they don't trigger another
|
|
22
|
+
* round of manifest persistence — otherwise an MS_UpsertManifest
|
|
23
|
+
* write to the beacon would itself create a manifest, which would
|
|
24
|
+
* persist via MS_UpsertManifest, which would create another...
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const libPictService = require('pict-serviceproviderbase');
|
|
28
|
+
const libFs = require('fs');
|
|
29
|
+
const libPath = require('path');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
32
|
+
|
|
33
|
+
// One sweep can't drain a years-long backlog in a single shot —
|
|
34
|
+
// successive reconnects continue from the HWM. Manifests are
|
|
35
|
+
// coarser than queue items, so a smaller per-sweep cap is enough.
|
|
36
|
+
const FLUSH_BATCH_LIMIT = 1000;
|
|
37
|
+
|
|
38
|
+
const HWM_FILENAME = 'persistence-bridge-hwm.json';
|
|
39
|
+
|
|
40
|
+
// Filename for the persisted persistence-beacon assignment (Session 3).
|
|
41
|
+
// See Ultravisor-QueuePersistenceBridge.cjs for the schema; this bridge
|
|
42
|
+
// owns the Manifest top-level key.
|
|
43
|
+
const ASSIGNMENT_FILENAME = 'persistence-assignment.json';
|
|
44
|
+
|
|
45
|
+
// Beacon Tag the lab stamps on a databeacon when it's been assigned
|
|
46
|
+
// as the UV's persistence backend. Same convention as the queue
|
|
47
|
+
// bridge — manifests share the connection so they share the tag.
|
|
48
|
+
const PERSISTENCE_TAG = 'PersistenceConnectionID';
|
|
49
|
+
|
|
50
|
+
const SCHEMA_DESCRIPTOR_PATH = libPath.join(__dirname, '..', 'persistence', 'UltravisorPersistenceSchema.json');
|
|
51
|
+
|
|
52
|
+
const MANIFEST_TABLES = ['UVManifest'];
|
|
53
|
+
|
|
54
|
+
const UV_PROXY_PATH_PATTERNS = ['^/?1\\.0/[^/]+/UV[A-Za-z0-9]*'];
|
|
55
|
+
|
|
56
|
+
class UltravisorManifestStoreBridge extends libPictService
|
|
57
|
+
{
|
|
58
|
+
constructor(pPict, pOptions, pServiceHash)
|
|
59
|
+
{
|
|
60
|
+
super(pPict, pOptions, pServiceHash);
|
|
61
|
+
this.serviceType = 'UltravisorManifestStoreBridge';
|
|
62
|
+
this._TimeoutMs = (pOptions && pOptions.TimeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
63
|
+
// Bootstrap-flush state: HWM keyed by beaconID. We anchor on
|
|
64
|
+
// each manifest's StopTime (terminal runs) or StartTime (still
|
|
65
|
+
// in flight when ultravisor crashed) so HWM advances
|
|
66
|
+
// monotonically. Idempotency is "free" via MS_UpsertManifest
|
|
67
|
+
// — RunHash is the natural key — so a missed HWM only means
|
|
68
|
+
// redundant uploads on the next sweep, not data corruption.
|
|
69
|
+
this._FlushHWMs = this._loadHWMs();
|
|
70
|
+
this._FlushInFlight = new Set();
|
|
71
|
+
// MeadowProxy bootstrap state. Mirrors the queue bridge — see
|
|
72
|
+
// Ultravisor-QueuePersistenceBridge.cjs for the rationale.
|
|
73
|
+
this._BootstrappedBeacons = new Set();
|
|
74
|
+
this._BootstrapInFlight = new Set();
|
|
75
|
+
this._EndpointBaseByBeacon = {};
|
|
76
|
+
this._SchemaDescriptor = this._loadSchemaDescriptor();
|
|
77
|
+
// Explicit lab assignment (Session 3). Tag-scan stays as the
|
|
78
|
+
// CLI-only fallback for sidecar deployments.
|
|
79
|
+
this._PersistenceAssignment = this._loadAssignment();
|
|
80
|
+
this._LastBootstrapError = null;
|
|
81
|
+
this._BootstrappedAt = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_loadSchemaDescriptor()
|
|
85
|
+
{
|
|
86
|
+
try
|
|
87
|
+
{
|
|
88
|
+
let tmpRaw = libFs.readFileSync(SCHEMA_DESCRIPTOR_PATH, 'utf8');
|
|
89
|
+
return JSON.parse(tmpRaw);
|
|
90
|
+
}
|
|
91
|
+
catch (pErr)
|
|
92
|
+
{
|
|
93
|
+
if (this.log)
|
|
94
|
+
{
|
|
95
|
+
this.log.warn(`ManifestStoreBridge: schema descriptor not loadable at ${SCHEMA_DESCRIPTOR_PATH} (${pErr.message}). MeadowProxy persistence path disabled.`);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getBeaconID()
|
|
102
|
+
{
|
|
103
|
+
let tmpCoord = this._coord();
|
|
104
|
+
if (!tmpCoord) return null;
|
|
105
|
+
let tmpBeacons = tmpCoord.listBeacons() || [];
|
|
106
|
+
for (let i = 0; i < tmpBeacons.length; i++)
|
|
107
|
+
{
|
|
108
|
+
let tmpCaps = tmpBeacons[i].Capabilities || [];
|
|
109
|
+
if (tmpCaps.indexOf('ManifestStore') >= 0)
|
|
110
|
+
{
|
|
111
|
+
return tmpBeacons[i].BeaconID;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
isBeaconAvailable()
|
|
118
|
+
{
|
|
119
|
+
return this.getBeaconID() !== null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Locate a MeadowProxy persistence beacon. Explicit lab assignment
|
|
124
|
+
* wins; tag-scan is the CLI-only fallback. Mirrors the queue
|
|
125
|
+
* bridge — see its getPersistenceBeacon for the rationale.
|
|
126
|
+
*/
|
|
127
|
+
getPersistenceBeacon()
|
|
128
|
+
{
|
|
129
|
+
if (!this._SchemaDescriptor) return null;
|
|
130
|
+
if (this._PersistenceAssignment && this._PersistenceAssignment.BeaconID)
|
|
131
|
+
{
|
|
132
|
+
return {
|
|
133
|
+
BeaconID: this._PersistenceAssignment.BeaconID,
|
|
134
|
+
IDBeaconConnection: this._PersistenceAssignment.IDBeaconConnection
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let tmpCoord = this._coord();
|
|
138
|
+
if (!tmpCoord) return null;
|
|
139
|
+
let tmpBeacons = tmpCoord.listBeacons() || [];
|
|
140
|
+
for (let i = 0; i < tmpBeacons.length; i++)
|
|
141
|
+
{
|
|
142
|
+
let tmpBeacon = tmpBeacons[i];
|
|
143
|
+
if (tmpBeacon.Status !== 'Online' && tmpBeacon.Status !== 'Busy') continue;
|
|
144
|
+
let tmpCaps = tmpBeacon.Capabilities || [];
|
|
145
|
+
if (tmpCaps.indexOf('MeadowProxy') < 0) continue;
|
|
146
|
+
let tmpConnID = tmpBeacon.Tags && tmpBeacon.Tags[PERSISTENCE_TAG];
|
|
147
|
+
if (tmpConnID === undefined || tmpConnID === null || tmpConnID === '') continue;
|
|
148
|
+
return { BeaconID: tmpBeacon.BeaconID, IDBeaconConnection: tmpConnID };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
isMeadowProxyMode()
|
|
154
|
+
{
|
|
155
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
156
|
+
if (!tmpAssigned) return false;
|
|
157
|
+
return this._BootstrappedBeacons.has(tmpAssigned.BeaconID);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============== Write API ==============
|
|
161
|
+
|
|
162
|
+
upsertManifest(pManifest)
|
|
163
|
+
{
|
|
164
|
+
return this._writeOrLocal('MS_UpsertManifest', { Manifest: pManifest },
|
|
165
|
+
(pSvc) =>
|
|
166
|
+
{
|
|
167
|
+
// Local fallback: the existing UltravisorExecutionManifest
|
|
168
|
+
// _writeManifest writes to disk in the staging path. We
|
|
169
|
+
// invoke that path directly when no beacon is connected.
|
|
170
|
+
// Caller is responsible for passing a manifest that
|
|
171
|
+
// includes a StagingPath — that's how the in-process
|
|
172
|
+
// service knows where to write.
|
|
173
|
+
if (typeof pSvc._writeManifest === 'function' && pManifest && pManifest.StagingPath)
|
|
174
|
+
{
|
|
175
|
+
pSvc._writeManifest(pManifest, pManifest.StagingPath);
|
|
176
|
+
return { Success: true };
|
|
177
|
+
}
|
|
178
|
+
return { Success: false, Reason: 'Local manifest service has no _writeManifest, or manifest missing StagingPath' };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
removeManifest(pRunHash)
|
|
183
|
+
{
|
|
184
|
+
return this._writeOrLocal('MS_RemoveManifest', { RunHash: pRunHash },
|
|
185
|
+
(pSvc) =>
|
|
186
|
+
{
|
|
187
|
+
// In-process: drop from the in-memory _Runs map. Disk
|
|
188
|
+
// staging folders aren't pruned here — abandonRun does
|
|
189
|
+
// that, and operators typically run a separate retention
|
|
190
|
+
// sweep.
|
|
191
|
+
if (pSvc._Runs && pSvc._Runs[pRunHash])
|
|
192
|
+
{
|
|
193
|
+
delete pSvc._Runs[pRunHash];
|
|
194
|
+
return { Success: true };
|
|
195
|
+
}
|
|
196
|
+
return { Success: false, Reason: 'Unknown manifest in local store' };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ============== Read API ==============
|
|
201
|
+
|
|
202
|
+
getManifest(pRunHash)
|
|
203
|
+
{
|
|
204
|
+
return this._readOrLocal('MS_GetManifest', { RunHash: pRunHash },
|
|
205
|
+
(pSvc) =>
|
|
206
|
+
{
|
|
207
|
+
let tmpRun = pSvc.getRun ? pSvc.getRun(pRunHash) : null;
|
|
208
|
+
return { Success: !!tmpRun, Manifest: tmpRun };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
listManifests(pFilter)
|
|
213
|
+
{
|
|
214
|
+
return this._readOrLocal('MS_ListManifests', { Filter: pFilter || {} },
|
|
215
|
+
(pSvc) =>
|
|
216
|
+
{
|
|
217
|
+
let tmpAll = (pSvc.listRuns ? pSvc.listRuns() : []) || [];
|
|
218
|
+
// Apply the same minimal filtering the memory provider
|
|
219
|
+
// supports, so callers see consistent semantics across
|
|
220
|
+
// backends.
|
|
221
|
+
let tmpFilter = pFilter || {};
|
|
222
|
+
if (tmpFilter.OperationHash)
|
|
223
|
+
{
|
|
224
|
+
tmpAll = tmpAll.filter((pM) => pM.OperationHash === tmpFilter.OperationHash);
|
|
225
|
+
}
|
|
226
|
+
if (tmpFilter.Status)
|
|
227
|
+
{
|
|
228
|
+
tmpAll = tmpAll.filter((pM) => pM.Status === tmpFilter.Status);
|
|
229
|
+
}
|
|
230
|
+
let tmpLimit = tmpFilter.Limit || 100;
|
|
231
|
+
return { Success: true, Manifests: tmpAll.slice(0, tmpLimit) };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============== Bootstrap-flush ==============
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Called by the coordinator when ANY beacon registers (or
|
|
239
|
+
* re-registers after a reconnect). We filter by capability —
|
|
240
|
+
* the notification fans out, but only ManifestStore beacons
|
|
241
|
+
* trigger the flush sweep here.
|
|
242
|
+
*
|
|
243
|
+
* Local source of truth is UltravisorExecutionManifest's
|
|
244
|
+
* in-memory `_Runs` map. Manifests that have already been
|
|
245
|
+
* dropped from memory (because they were persisted to disk
|
|
246
|
+
* and never re-loaded) won't be flushed — the operator-facing
|
|
247
|
+
* /Manifest endpoints already merge live + bridge-historical
|
|
248
|
+
* reads, so historic manifests stay reachable via the merge
|
|
249
|
+
* even if the beacon doesn't have them. Documented assumption:
|
|
250
|
+
* if you crash ultravisor with a finalized-but-not-loaded
|
|
251
|
+
* manifest, the beacon won't backfill it from disk; for that,
|
|
252
|
+
* a future "rescan-on-demand" command would walk the staging
|
|
253
|
+
* dir.
|
|
254
|
+
*/
|
|
255
|
+
onBeaconConnected(pBeaconID)
|
|
256
|
+
{
|
|
257
|
+
if (!pBeaconID) return;
|
|
258
|
+
// Try the MeadowProxy bootstrap first; the helper short-circuits
|
|
259
|
+
// if this beacon isn't the assigned persistence backend.
|
|
260
|
+
this._handleMeadowProxyBootstrap(pBeaconID);
|
|
261
|
+
let tmpMSID = this.getBeaconID();
|
|
262
|
+
if (tmpMSID !== pBeaconID) return;
|
|
263
|
+
if (this._FlushInFlight.has(pBeaconID)) return;
|
|
264
|
+
let tmpSvc = this._localService();
|
|
265
|
+
if (!tmpSvc || typeof tmpSvc.listRuns !== 'function') return;
|
|
266
|
+
this._FlushInFlight.add(pBeaconID);
|
|
267
|
+
this._flushManifestsToBeacon(pBeaconID, tmpSvc)
|
|
268
|
+
.then((pCount) =>
|
|
269
|
+
{
|
|
270
|
+
this._FlushInFlight.delete(pBeaconID);
|
|
271
|
+
if (pCount > 0)
|
|
272
|
+
{
|
|
273
|
+
this.log.info(`ManifestStoreBridge: bootstrap-flush pushed ${pCount} manifest(s) to beacon [${pBeaconID}].`);
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
.catch((pErr) =>
|
|
277
|
+
{
|
|
278
|
+
this._FlushInFlight.delete(pBeaconID);
|
|
279
|
+
this.log.warn(`ManifestStoreBridge: bootstrap-flush failed: ${pErr && pErr.message}`);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _flushManifestsToBeacon(pBeaconID, pSvc)
|
|
284
|
+
{
|
|
285
|
+
let tmpHWM = this._FlushHWMs[pBeaconID] || '';
|
|
286
|
+
let tmpAll = pSvc.listRuns() || [];
|
|
287
|
+
// StopTime ASC for finalized runs so HWM marches forward
|
|
288
|
+
// monotonically; un-stopped (still-running) runs sort last
|
|
289
|
+
// by falling back to StartTime. _runAnchor() projects the
|
|
290
|
+
// timestamp we use as the HWM key.
|
|
291
|
+
tmpAll.sort((a, b) =>
|
|
292
|
+
{
|
|
293
|
+
let tmpA = this._runAnchor(a);
|
|
294
|
+
let tmpB = this._runAnchor(b);
|
|
295
|
+
if (tmpA < tmpB) return -1;
|
|
296
|
+
if (tmpA > tmpB) return 1;
|
|
297
|
+
return 0;
|
|
298
|
+
});
|
|
299
|
+
if (tmpAll.length > FLUSH_BATCH_LIMIT)
|
|
300
|
+
{
|
|
301
|
+
tmpAll = tmpAll.slice(0, FLUSH_BATCH_LIMIT);
|
|
302
|
+
}
|
|
303
|
+
let tmpPushed = 0;
|
|
304
|
+
for (let i = 0; i < tmpAll.length; i++)
|
|
305
|
+
{
|
|
306
|
+
let tmpRun = tmpAll[i];
|
|
307
|
+
let tmpAnchor = this._runAnchor(tmpRun);
|
|
308
|
+
if (tmpHWM && tmpAnchor && tmpAnchor <= tmpHWM) continue;
|
|
309
|
+
// Build the JSON-serializable shape MS_UpsertManifest
|
|
310
|
+
// expects. Mirrors what UltravisorExecutionManifest's
|
|
311
|
+
// _persistManifestViaBridge produces, kept inline here
|
|
312
|
+
// because the local _Runs entries are full
|
|
313
|
+
// ExecutionContext objects (with closures); we strip to
|
|
314
|
+
// the wire-safe subset.
|
|
315
|
+
let tmpManifest = this._wireSafeManifest(tmpRun);
|
|
316
|
+
if (!tmpManifest) continue;
|
|
317
|
+
let tmpResult = await this._dispatch('MS_UpsertManifest', { Manifest: tmpManifest });
|
|
318
|
+
if (!tmpResult || !tmpResult.Success) break;
|
|
319
|
+
tmpHWM = tmpAnchor || tmpHWM;
|
|
320
|
+
this._FlushHWMs[pBeaconID] = tmpHWM;
|
|
321
|
+
this._saveHWMs();
|
|
322
|
+
tmpPushed += 1;
|
|
323
|
+
}
|
|
324
|
+
return tmpPushed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_runAnchor(pRun)
|
|
328
|
+
{
|
|
329
|
+
if (!pRun) return '';
|
|
330
|
+
// Prefer terminal time so finalized runs cluster cleanly at
|
|
331
|
+
// the front of the sweep order. In-flight runs use StartTime
|
|
332
|
+
// — they re-flush on each subsequent sweep until they finalize.
|
|
333
|
+
return pRun.StopTime || pRun.StartTime || '';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_wireSafeManifest(pRun)
|
|
337
|
+
{
|
|
338
|
+
if (!pRun) return null;
|
|
339
|
+
// Match the projection the existing /Manifest endpoint and
|
|
340
|
+
// _persistManifestViaBridge use. Anything carrying closures
|
|
341
|
+
// (PendingEvents, callback queues) gets dropped here.
|
|
342
|
+
return {
|
|
343
|
+
Hash: pRun.Hash,
|
|
344
|
+
OperationHash: pRun.OperationHash,
|
|
345
|
+
OperationName: pRun.OperationName,
|
|
346
|
+
Status: pRun.Status,
|
|
347
|
+
RunMode: pRun.RunMode,
|
|
348
|
+
Live: pRun.Live || false,
|
|
349
|
+
StartTime: pRun.StartTime,
|
|
350
|
+
StopTime: pRun.StopTime,
|
|
351
|
+
ElapsedMs: pRun.ElapsedMs,
|
|
352
|
+
Output: pRun.Output || {},
|
|
353
|
+
GlobalState: pRun.GlobalState || {},
|
|
354
|
+
OperationState: pRun.OperationState || {},
|
|
355
|
+
TaskOutputs: pRun.TaskOutputs || {},
|
|
356
|
+
TaskManifests: pRun.TaskManifests || {},
|
|
357
|
+
WaitingTasks: pRun.WaitingTasks || {},
|
|
358
|
+
TimingSummary: pRun.TimingSummary || null,
|
|
359
|
+
EventLog: pRun.EventLog || [],
|
|
360
|
+
Errors: pRun.Errors || [],
|
|
361
|
+
Log: pRun.Log || [],
|
|
362
|
+
StagingPath: pRun.StagingPath || ''
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_loadHWMs()
|
|
367
|
+
{
|
|
368
|
+
try
|
|
369
|
+
{
|
|
370
|
+
let tmpPath = this._hwmPath();
|
|
371
|
+
if (!tmpPath || !libFs.existsSync(tmpPath)) return {};
|
|
372
|
+
let tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8'));
|
|
373
|
+
return (tmpDoc && tmpDoc.Manifest) || {};
|
|
374
|
+
}
|
|
375
|
+
catch (pErr) { return {}; }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_saveHWMs()
|
|
379
|
+
{
|
|
380
|
+
try
|
|
381
|
+
{
|
|
382
|
+
let tmpPath = this._hwmPath();
|
|
383
|
+
if (!tmpPath) return;
|
|
384
|
+
let tmpDoc = {};
|
|
385
|
+
if (libFs.existsSync(tmpPath))
|
|
386
|
+
{
|
|
387
|
+
try { tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8')) || {}; }
|
|
388
|
+
catch (e) { tmpDoc = {}; }
|
|
389
|
+
}
|
|
390
|
+
tmpDoc.Manifest = this._FlushHWMs;
|
|
391
|
+
libFs.writeFileSync(tmpPath, JSON.stringify(tmpDoc, null, '\t'), 'utf8');
|
|
392
|
+
}
|
|
393
|
+
catch (pErr)
|
|
394
|
+
{
|
|
395
|
+
this.log.warn(`ManifestStoreBridge: HWM save failed: ${pErr.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_hwmPath()
|
|
400
|
+
{
|
|
401
|
+
let tmpDataPath = (this.fable && this.fable.settings && this.fable.settings.UltravisorFileStorePath)
|
|
402
|
+
|| (this.fable && this.fable.settings && this.fable.settings.DataPath)
|
|
403
|
+
|| null;
|
|
404
|
+
if (!tmpDataPath) return null;
|
|
405
|
+
return libPath.join(tmpDataPath, HWM_FILENAME);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ============== Persistence assignment (Session 3) ==============
|
|
409
|
+
|
|
410
|
+
setPersistenceAssignment(pBeaconID, pIDBeaconConnection)
|
|
411
|
+
{
|
|
412
|
+
let tmpPrev = this._PersistenceAssignment;
|
|
413
|
+
if (tmpPrev && tmpPrev.BeaconID && tmpPrev.BeaconID !== pBeaconID)
|
|
414
|
+
{
|
|
415
|
+
this._BootstrappedBeacons.delete(tmpPrev.BeaconID);
|
|
416
|
+
delete this._EndpointBaseByBeacon[tmpPrev.BeaconID];
|
|
417
|
+
}
|
|
418
|
+
if (!pBeaconID)
|
|
419
|
+
{
|
|
420
|
+
this._PersistenceAssignment = null;
|
|
421
|
+
}
|
|
422
|
+
else
|
|
423
|
+
{
|
|
424
|
+
this._PersistenceAssignment =
|
|
425
|
+
{
|
|
426
|
+
BeaconID: pBeaconID,
|
|
427
|
+
IDBeaconConnection: pIDBeaconConnection || 0,
|
|
428
|
+
AssignedAt: new Date().toISOString()
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
this._LastBootstrapError = null;
|
|
432
|
+
this._BootstrappedAt = null;
|
|
433
|
+
this._saveAssignment();
|
|
434
|
+
if (!pBeaconID) return;
|
|
435
|
+
let tmpCoord = this._coord();
|
|
436
|
+
let tmpBeacon = tmpCoord && typeof tmpCoord.getBeacon === 'function' ? tmpCoord.getBeacon(pBeaconID) : null;
|
|
437
|
+
if (tmpBeacon && (tmpBeacon.Status === 'Online' || tmpBeacon.Status === 'Busy'))
|
|
438
|
+
{
|
|
439
|
+
setImmediate(() => this._handleMeadowProxyBootstrap(pBeaconID));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
clearPersistenceAssignment()
|
|
444
|
+
{
|
|
445
|
+
this.setPersistenceAssignment(null, 0);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
getPersistenceStatus()
|
|
449
|
+
{
|
|
450
|
+
let tmpAssigned = this._PersistenceAssignment || null;
|
|
451
|
+
let tmpBeaconID = tmpAssigned && tmpAssigned.BeaconID || null;
|
|
452
|
+
let tmpState;
|
|
453
|
+
if (!tmpBeaconID)
|
|
454
|
+
{
|
|
455
|
+
tmpState = 'unassigned';
|
|
456
|
+
}
|
|
457
|
+
else if (this._LastBootstrapError)
|
|
458
|
+
{
|
|
459
|
+
tmpState = 'error';
|
|
460
|
+
}
|
|
461
|
+
else if (this._BootstrappedBeacons.has(tmpBeaconID))
|
|
462
|
+
{
|
|
463
|
+
tmpState = 'bootstrapped';
|
|
464
|
+
}
|
|
465
|
+
else if (this._BootstrapInFlight.has(tmpBeaconID))
|
|
466
|
+
{
|
|
467
|
+
tmpState = 'bootstrapping';
|
|
468
|
+
}
|
|
469
|
+
else
|
|
470
|
+
{
|
|
471
|
+
tmpState = 'waiting-for-beacon';
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
State: tmpState,
|
|
475
|
+
AssignedBeaconID: tmpBeaconID,
|
|
476
|
+
IDBeaconConnection: tmpAssigned ? (tmpAssigned.IDBeaconConnection || 0) : 0,
|
|
477
|
+
LastError: this._LastBootstrapError || null,
|
|
478
|
+
BootstrappedAt: this._BootstrappedAt || null,
|
|
479
|
+
AssignedAt: tmpAssigned ? (tmpAssigned.AssignedAt || null) : null
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_loadAssignment()
|
|
484
|
+
{
|
|
485
|
+
try
|
|
486
|
+
{
|
|
487
|
+
let tmpPath = this._assignmentPath();
|
|
488
|
+
if (!tmpPath || !libFs.existsSync(tmpPath)) return null;
|
|
489
|
+
let tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8'));
|
|
490
|
+
let tmpEntry = tmpDoc && tmpDoc.Manifest;
|
|
491
|
+
if (!tmpEntry || !tmpEntry.BeaconID) return null;
|
|
492
|
+
return tmpEntry;
|
|
493
|
+
}
|
|
494
|
+
catch (pErr) { return null; }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
_saveAssignment()
|
|
498
|
+
{
|
|
499
|
+
try
|
|
500
|
+
{
|
|
501
|
+
let tmpPath = this._assignmentPath();
|
|
502
|
+
if (!tmpPath) return;
|
|
503
|
+
let tmpDoc = {};
|
|
504
|
+
if (libFs.existsSync(tmpPath))
|
|
505
|
+
{
|
|
506
|
+
try { tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8')) || {}; }
|
|
507
|
+
catch (e) { tmpDoc = {}; }
|
|
508
|
+
}
|
|
509
|
+
tmpDoc.Manifest = this._PersistenceAssignment || null;
|
|
510
|
+
libFs.writeFileSync(tmpPath, JSON.stringify(tmpDoc, null, '\t'), 'utf8');
|
|
511
|
+
}
|
|
512
|
+
catch (pErr)
|
|
513
|
+
{
|
|
514
|
+
if (this.log) this.log.warn(`ManifestStoreBridge: assignment save failed: ${pErr.message}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
_assignmentPath()
|
|
519
|
+
{
|
|
520
|
+
let tmpDataPath = (this.fable && this.fable.settings && this.fable.settings.UltravisorFileStorePath)
|
|
521
|
+
|| (this.fable && this.fable.settings && this.fable.settings.DataPath)
|
|
522
|
+
|| null;
|
|
523
|
+
if (!tmpDataPath) return null;
|
|
524
|
+
return libPath.join(tmpDataPath, ASSIGNMENT_FILENAME);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ============== Internals ==============
|
|
528
|
+
|
|
529
|
+
_coord()
|
|
530
|
+
{
|
|
531
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
532
|
+
&& this.fable.servicesMap.UltravisorBeaconCoordinator;
|
|
533
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
_localService()
|
|
537
|
+
{
|
|
538
|
+
// Resolve lazily — the existing UltravisorExecutionManifest
|
|
539
|
+
// service is the local fallback target.
|
|
540
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
541
|
+
&& this.fable.servicesMap.UltravisorExecutionManifest;
|
|
542
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_writeOrLocal(pAction, pSettings, fLocal)
|
|
546
|
+
{
|
|
547
|
+
if (this.isMeadowProxyMode())
|
|
548
|
+
{
|
|
549
|
+
return this._dispatchViaMeadowProxy(pAction, pSettings);
|
|
550
|
+
}
|
|
551
|
+
if (this.isBeaconAvailable())
|
|
552
|
+
{
|
|
553
|
+
return this._dispatch(pAction, pSettings);
|
|
554
|
+
}
|
|
555
|
+
let tmpSvc = this._localService();
|
|
556
|
+
if (!tmpSvc)
|
|
557
|
+
{
|
|
558
|
+
return Promise.resolve(
|
|
559
|
+
{
|
|
560
|
+
Available: false, Success: false,
|
|
561
|
+
Reason: 'No manifest backend (beacon or local) available'
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
try
|
|
565
|
+
{
|
|
566
|
+
let tmpResult = fLocal(tmpSvc);
|
|
567
|
+
return Promise.resolve(Object.assign({ Available: true }, tmpResult));
|
|
568
|
+
}
|
|
569
|
+
catch (pErr)
|
|
570
|
+
{
|
|
571
|
+
return Promise.resolve(
|
|
572
|
+
{
|
|
573
|
+
Available: true, Success: false,
|
|
574
|
+
Reason: (pErr && pErr.message) || String(pErr)
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
_readOrLocal(pAction, pSettings, fLocal)
|
|
580
|
+
{
|
|
581
|
+
if (this.isMeadowProxyMode())
|
|
582
|
+
{
|
|
583
|
+
return this._dispatchViaMeadowProxy(pAction, pSettings);
|
|
584
|
+
}
|
|
585
|
+
if (this.isBeaconAvailable())
|
|
586
|
+
{
|
|
587
|
+
return this._dispatch(pAction, pSettings);
|
|
588
|
+
}
|
|
589
|
+
let tmpSvc = this._localService();
|
|
590
|
+
if (!tmpSvc)
|
|
591
|
+
{
|
|
592
|
+
return Promise.resolve(
|
|
593
|
+
{
|
|
594
|
+
Available: false, Success: false,
|
|
595
|
+
Reason: 'No manifest backend available',
|
|
596
|
+
Manifest: null, Manifests: []
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
try
|
|
600
|
+
{
|
|
601
|
+
let tmpResult = fLocal(tmpSvc);
|
|
602
|
+
return Promise.resolve(Object.assign({ Available: true }, tmpResult));
|
|
603
|
+
}
|
|
604
|
+
catch (pErr)
|
|
605
|
+
{
|
|
606
|
+
return Promise.resolve(
|
|
607
|
+
{
|
|
608
|
+
Available: true, Success: false,
|
|
609
|
+
Reason: (pErr && pErr.message) || String(pErr)
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
_dispatch(pAction, pSettings)
|
|
615
|
+
{
|
|
616
|
+
return new Promise((fResolve) =>
|
|
617
|
+
{
|
|
618
|
+
let tmpCoord = this._coord();
|
|
619
|
+
if (!tmpCoord)
|
|
620
|
+
{
|
|
621
|
+
return fResolve(
|
|
622
|
+
{
|
|
623
|
+
Available: false, Success: false,
|
|
624
|
+
Reason: 'BeaconCoordinator not available'
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
tmpCoord.dispatchAndWait(
|
|
628
|
+
{
|
|
629
|
+
Capability: 'ManifestStore',
|
|
630
|
+
Action: pAction,
|
|
631
|
+
Settings: pSettings || {},
|
|
632
|
+
AffinityKey: 'manifest-store',
|
|
633
|
+
TimeoutMs: this._TimeoutMs
|
|
634
|
+
},
|
|
635
|
+
(pError, pResult) =>
|
|
636
|
+
{
|
|
637
|
+
if (pError)
|
|
638
|
+
{
|
|
639
|
+
return fResolve(
|
|
640
|
+
{
|
|
641
|
+
Available: true, Success: false,
|
|
642
|
+
Reason: pError.message || String(pError)
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
646
|
+
return fResolve(Object.assign({ Available: true }, tmpOut));
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ============== MeadowProxy dispatch path ==============
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Translate an MS_* action onto a MeadowProxy REST request and
|
|
655
|
+
* dispatch it through the assigned databeacon.
|
|
656
|
+
*
|
|
657
|
+
* Translation table (see `docs/features/persistence-via-databeacon.md`):
|
|
658
|
+
*
|
|
659
|
+
* MS_UpsertManifest → POST /1.0/<hash>/UVManifest
|
|
660
|
+
* (relies on `Hash` unique index for
|
|
661
|
+
* idempotency; PUT-by-hash needs a hash→ID
|
|
662
|
+
* lookup that lands with the lab UI work).
|
|
663
|
+
* MS_RemoveManifest → deferred (soft-delete needs the IDRecord).
|
|
664
|
+
* MS_GetManifest → GET /1.0/<hash>/UVManifests/FilteredTo/FBV~Hash~EQ~<hash>
|
|
665
|
+
* MS_ListManifests → GET /1.0/<hash>/UVManifests
|
|
666
|
+
*/
|
|
667
|
+
_dispatchViaMeadowProxy(pAction, pSettings)
|
|
668
|
+
{
|
|
669
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
670
|
+
if (!tmpAssigned)
|
|
671
|
+
{
|
|
672
|
+
return Promise.resolve(
|
|
673
|
+
{
|
|
674
|
+
Available: false, Success: false,
|
|
675
|
+
Reason: 'No MeadowProxy persistence beacon assigned'
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// MS_RemoveManifest is a soft-delete via meadow's DELETE endpoint —
|
|
679
|
+
// because the schema declares `Deleted` as Type='Deleted', meadow
|
|
680
|
+
// flips the column rather than hard-removing. We still need a
|
|
681
|
+
// hash → IDRecord lookup first because meadow's DELETE-by-id
|
|
682
|
+
// addresses by primary key.
|
|
683
|
+
if (pAction === 'MS_RemoveManifest')
|
|
684
|
+
{
|
|
685
|
+
return this._dispatchDeleteByHash(tmpAssigned.BeaconID,
|
|
686
|
+
'UVManifest', 'IDUVManifest',
|
|
687
|
+
'Hash', pSettings && pSettings.RunHash);
|
|
688
|
+
}
|
|
689
|
+
return new Promise((fResolve) =>
|
|
690
|
+
{
|
|
691
|
+
let tmpReq;
|
|
692
|
+
try
|
|
693
|
+
{
|
|
694
|
+
tmpReq = this._buildMeadowProxyRequest(pAction, pSettings, tmpAssigned.BeaconID);
|
|
695
|
+
}
|
|
696
|
+
catch (pErr)
|
|
697
|
+
{
|
|
698
|
+
return fResolve(
|
|
699
|
+
{
|
|
700
|
+
Available: true, Success: false,
|
|
701
|
+
Reason: pErr.message || String(pErr)
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (!tmpReq)
|
|
705
|
+
{
|
|
706
|
+
return fResolve(
|
|
707
|
+
{
|
|
708
|
+
Available: true, Success: false,
|
|
709
|
+
Reason: `Action [${pAction}] is not yet wired through MeadowProxy`
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let tmpCoord = this._coord();
|
|
714
|
+
if (!tmpCoord)
|
|
715
|
+
{
|
|
716
|
+
return fResolve(
|
|
717
|
+
{
|
|
718
|
+
Available: false, Success: false,
|
|
719
|
+
Reason: 'BeaconCoordinator not available'
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
tmpCoord.dispatchAndWait(
|
|
723
|
+
{
|
|
724
|
+
Capability: 'MeadowProxy',
|
|
725
|
+
Action: 'Request',
|
|
726
|
+
Settings: tmpReq,
|
|
727
|
+
AffinityKey: 'manifest-store',
|
|
728
|
+
TimeoutMs: this._TimeoutMs
|
|
729
|
+
},
|
|
730
|
+
(pError, pResult) =>
|
|
731
|
+
{
|
|
732
|
+
if (pError)
|
|
733
|
+
{
|
|
734
|
+
return fResolve(
|
|
735
|
+
{
|
|
736
|
+
Available: true, Success: false,
|
|
737
|
+
Reason: pError.message || String(pError)
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
741
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
742
|
+
let tmpBody = tmpOut.Body;
|
|
743
|
+
let tmpSuccess = (tmpStatus >= 200 && tmpStatus < 300);
|
|
744
|
+
return fResolve(this._normalizeMeadowProxyResult(pAction, tmpStatus, tmpBody, tmpSuccess));
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_buildMeadowProxyRequest(pAction, pSettings, pBeaconID)
|
|
750
|
+
{
|
|
751
|
+
let tmpBase = this._endpointBase(pBeaconID, 'UVManifest');
|
|
752
|
+
let tmpUser = this._resolveRemoteUser();
|
|
753
|
+
switch (pAction)
|
|
754
|
+
{
|
|
755
|
+
case 'MS_UpsertManifest':
|
|
756
|
+
{
|
|
757
|
+
let tmpManifest = pSettings && pSettings.Manifest;
|
|
758
|
+
if (!tmpManifest) { throw new Error('MS_UpsertManifest: Manifest is required.'); }
|
|
759
|
+
// Project onto the UVManifest schema. Anything not listed
|
|
760
|
+
// here would be rejected by meadow ("table has no column
|
|
761
|
+
// named X") since we only created the columns the schema
|
|
762
|
+
// descriptor declares. The full manifest payload survives
|
|
763
|
+
// in the ManifestJSON blob.
|
|
764
|
+
let tmpRow =
|
|
765
|
+
{
|
|
766
|
+
Hash: tmpManifest.Hash || '',
|
|
767
|
+
OperationHash: tmpManifest.OperationHash || '',
|
|
768
|
+
OperationName: tmpManifest.OperationName || '',
|
|
769
|
+
Status: tmpManifest.Status || '',
|
|
770
|
+
RunMode: tmpManifest.RunMode || '',
|
|
771
|
+
Live: !!tmpManifest.Live,
|
|
772
|
+
StartTime: tmpManifest.StartTime || '',
|
|
773
|
+
StopTime: tmpManifest.StopTime || '',
|
|
774
|
+
ElapsedMs: tmpManifest.ElapsedMs || 0,
|
|
775
|
+
StagingPath: tmpManifest.StagingPath || '',
|
|
776
|
+
ManifestJSON: typeof tmpManifest.ManifestJSON === 'string'
|
|
777
|
+
? tmpManifest.ManifestJSON
|
|
778
|
+
: JSON.stringify(tmpManifest)
|
|
779
|
+
};
|
|
780
|
+
return { Method: 'POST', Path: tmpBase, Body: JSON.stringify(tmpRow), RemoteUser: tmpUser };
|
|
781
|
+
}
|
|
782
|
+
case 'MS_GetManifest':
|
|
783
|
+
{
|
|
784
|
+
let tmpHash = pSettings && pSettings.RunHash;
|
|
785
|
+
if (!tmpHash) { throw new Error('MS_GetManifest: RunHash is required.'); }
|
|
786
|
+
return { Method: 'GET', Path: `${tmpBase}s/FilteredTo/FBV~Hash~EQ~${encodeURIComponent(tmpHash)}`, RemoteUser: tmpUser };
|
|
787
|
+
}
|
|
788
|
+
case 'MS_ListManifests':
|
|
789
|
+
{
|
|
790
|
+
return { Method: 'GET', Path: `${tmpBase}s`, RemoteUser: tmpUser };
|
|
791
|
+
}
|
|
792
|
+
default:
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
_normalizeMeadowProxyResult(pAction, pStatus, pBody, pSuccess)
|
|
798
|
+
{
|
|
799
|
+
let tmpParsed = null;
|
|
800
|
+
if (typeof pBody === 'string' && pBody.length > 0)
|
|
801
|
+
{
|
|
802
|
+
try { tmpParsed = JSON.parse(pBody); }
|
|
803
|
+
catch (pErr) { tmpParsed = null; }
|
|
804
|
+
}
|
|
805
|
+
else if (typeof pBody === 'object')
|
|
806
|
+
{
|
|
807
|
+
tmpParsed = pBody;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
switch (pAction)
|
|
811
|
+
{
|
|
812
|
+
case 'MS_GetManifest':
|
|
813
|
+
{
|
|
814
|
+
let tmpRow = Array.isArray(tmpParsed) ? (tmpParsed[0] || null) : null;
|
|
815
|
+
return { Available: true, Success: !!tmpRow, Manifest: tmpRow };
|
|
816
|
+
}
|
|
817
|
+
case 'MS_ListManifests':
|
|
818
|
+
return { Available: true, Success: pSuccess, Manifests: Array.isArray(tmpParsed) ? tmpParsed : [] };
|
|
819
|
+
default:
|
|
820
|
+
if (pSuccess)
|
|
821
|
+
{
|
|
822
|
+
return { Available: true, Success: true, Body: tmpParsed };
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
Available: true, Success: false,
|
|
826
|
+
Status: pStatus,
|
|
827
|
+
Reason: (tmpParsed && (tmpParsed.error || tmpParsed.message)) || `MeadowProxy ${pStatus}`
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
_endpointBase(pBeaconID, pTableName)
|
|
833
|
+
{
|
|
834
|
+
let tmpCache = this._EndpointBaseByBeacon[pBeaconID] || {};
|
|
835
|
+
if (tmpCache[pTableName]) { return tmpCache[pTableName]; }
|
|
836
|
+
return `/1.0/${pTableName}`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Pass-through value for MeadowProxy.Request's `RemoteUser` field.
|
|
841
|
+
* See the queue bridge — same rationale, same future plumbing.
|
|
842
|
+
*/
|
|
843
|
+
_resolveRemoteUser()
|
|
844
|
+
{
|
|
845
|
+
return 'ultravisor-system';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
_lookupIDByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue)
|
|
849
|
+
{
|
|
850
|
+
return new Promise((fResolve) =>
|
|
851
|
+
{
|
|
852
|
+
let tmpCoord = this._coord();
|
|
853
|
+
if (!tmpCoord)
|
|
854
|
+
{
|
|
855
|
+
return fResolve({ Success: false, Reason: 'BeaconCoordinator not available' });
|
|
856
|
+
}
|
|
857
|
+
if (!pHashValue)
|
|
858
|
+
{
|
|
859
|
+
return fResolve({ Success: false, Reason: `${pHashColumn} is required for lookup` });
|
|
860
|
+
}
|
|
861
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
862
|
+
let tmpReq =
|
|
863
|
+
{
|
|
864
|
+
Method: 'GET',
|
|
865
|
+
Path: `${tmpBase}s/FilteredTo/FBV~${pHashColumn}~EQ~${encodeURIComponent(pHashValue)}`,
|
|
866
|
+
RemoteUser: this._resolveRemoteUser()
|
|
867
|
+
};
|
|
868
|
+
tmpCoord.dispatchAndWait(
|
|
869
|
+
{
|
|
870
|
+
Capability: 'MeadowProxy',
|
|
871
|
+
Action: 'Request',
|
|
872
|
+
Settings: tmpReq,
|
|
873
|
+
AffinityKey: 'manifest-store',
|
|
874
|
+
TimeoutMs: this._TimeoutMs
|
|
875
|
+
},
|
|
876
|
+
(pError, pResult) =>
|
|
877
|
+
{
|
|
878
|
+
if (pError) return fResolve({ Success: false, Reason: pError.message || String(pError) });
|
|
879
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
880
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
881
|
+
if (tmpStatus < 200 || tmpStatus >= 300)
|
|
882
|
+
{
|
|
883
|
+
return fResolve({ Success: false, Reason: `Lookup failed: HTTP ${tmpStatus}` });
|
|
884
|
+
}
|
|
885
|
+
let tmpParsed = null;
|
|
886
|
+
if (typeof tmpOut.Body === 'string' && tmpOut.Body.length > 0)
|
|
887
|
+
{
|
|
888
|
+
try { tmpParsed = JSON.parse(tmpOut.Body); }
|
|
889
|
+
catch (e) { tmpParsed = null; }
|
|
890
|
+
}
|
|
891
|
+
else if (typeof tmpOut.Body === 'object')
|
|
892
|
+
{
|
|
893
|
+
tmpParsed = tmpOut.Body;
|
|
894
|
+
}
|
|
895
|
+
let tmpRow = Array.isArray(tmpParsed) ? (tmpParsed[0] || null) : null;
|
|
896
|
+
if (!tmpRow)
|
|
897
|
+
{
|
|
898
|
+
return fResolve({ Success: true, Found: false, IDRecord: null });
|
|
899
|
+
}
|
|
900
|
+
return fResolve({ Success: true, Found: true, IDRecord: tmpRow[pIDColumn], Row: tmpRow });
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async _dispatchUpdateByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue, pPatch)
|
|
906
|
+
{
|
|
907
|
+
let tmpLookup = await this._lookupIDByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue);
|
|
908
|
+
if (!tmpLookup.Success)
|
|
909
|
+
{
|
|
910
|
+
return { Available: true, Success: false, Reason: tmpLookup.Reason };
|
|
911
|
+
}
|
|
912
|
+
if (!tmpLookup.Found)
|
|
913
|
+
{
|
|
914
|
+
return { Available: true, Success: false, Reason: `${pTable} row with ${pHashColumn}=${pHashValue} not found` };
|
|
915
|
+
}
|
|
916
|
+
let tmpBody = Object.assign({}, pPatch || {});
|
|
917
|
+
tmpBody[pIDColumn] = tmpLookup.IDRecord;
|
|
918
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
919
|
+
return this._putByID(tmpBase, tmpLookup.IDRecord, tmpBody);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async _dispatchDeleteByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue)
|
|
923
|
+
{
|
|
924
|
+
let tmpLookup = await this._lookupIDByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue);
|
|
925
|
+
if (!tmpLookup.Success)
|
|
926
|
+
{
|
|
927
|
+
return { Available: true, Success: false, Reason: tmpLookup.Reason };
|
|
928
|
+
}
|
|
929
|
+
if (!tmpLookup.Found)
|
|
930
|
+
{
|
|
931
|
+
return { Available: true, Success: true, AlreadyAbsent: true };
|
|
932
|
+
}
|
|
933
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
934
|
+
return new Promise((fResolve) =>
|
|
935
|
+
{
|
|
936
|
+
let tmpCoord = this._coord();
|
|
937
|
+
if (!tmpCoord)
|
|
938
|
+
{
|
|
939
|
+
return fResolve({ Available: false, Success: false, Reason: 'BeaconCoordinator not available' });
|
|
940
|
+
}
|
|
941
|
+
let tmpReq =
|
|
942
|
+
{
|
|
943
|
+
Method: 'DELETE',
|
|
944
|
+
Path: `${tmpBase}/${encodeURIComponent(tmpLookup.IDRecord)}`,
|
|
945
|
+
RemoteUser: this._resolveRemoteUser()
|
|
946
|
+
};
|
|
947
|
+
tmpCoord.dispatchAndWait(
|
|
948
|
+
{
|
|
949
|
+
Capability: 'MeadowProxy',
|
|
950
|
+
Action: 'Request',
|
|
951
|
+
Settings: tmpReq,
|
|
952
|
+
AffinityKey: 'manifest-store',
|
|
953
|
+
TimeoutMs: this._TimeoutMs
|
|
954
|
+
},
|
|
955
|
+
(pError, pResult) =>
|
|
956
|
+
{
|
|
957
|
+
if (pError)
|
|
958
|
+
{
|
|
959
|
+
return fResolve({ Available: true, Success: false, Reason: pError.message || String(pError) });
|
|
960
|
+
}
|
|
961
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
962
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
963
|
+
let tmpSuccess = (tmpStatus >= 200 && tmpStatus < 300);
|
|
964
|
+
if (!tmpSuccess)
|
|
965
|
+
{
|
|
966
|
+
return fResolve({ Available: true, Success: false, Status: tmpStatus, Reason: `DELETE returned ${tmpStatus}` });
|
|
967
|
+
}
|
|
968
|
+
return fResolve({ Available: true, Success: true });
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
_putByID(pEndpointBase, pIDRecord, pBody)
|
|
974
|
+
{
|
|
975
|
+
return new Promise((fResolve) =>
|
|
976
|
+
{
|
|
977
|
+
let tmpCoord = this._coord();
|
|
978
|
+
if (!tmpCoord)
|
|
979
|
+
{
|
|
980
|
+
return fResolve({ Available: false, Success: false, Reason: 'BeaconCoordinator not available' });
|
|
981
|
+
}
|
|
982
|
+
// Meadow's Update endpoint is `PUT <base>` — PK travels in the body.
|
|
983
|
+
let tmpReq =
|
|
984
|
+
{
|
|
985
|
+
Method: 'PUT',
|
|
986
|
+
Path: pEndpointBase,
|
|
987
|
+
Body: JSON.stringify(pBody),
|
|
988
|
+
RemoteUser: this._resolveRemoteUser()
|
|
989
|
+
};
|
|
990
|
+
tmpCoord.dispatchAndWait(
|
|
991
|
+
{
|
|
992
|
+
Capability: 'MeadowProxy',
|
|
993
|
+
Action: 'Request',
|
|
994
|
+
Settings: tmpReq,
|
|
995
|
+
AffinityKey: 'manifest-store',
|
|
996
|
+
TimeoutMs: this._TimeoutMs
|
|
997
|
+
},
|
|
998
|
+
(pError, pResult) =>
|
|
999
|
+
{
|
|
1000
|
+
if (pError)
|
|
1001
|
+
{
|
|
1002
|
+
return fResolve({ Available: true, Success: false, Reason: pError.message || String(pError) });
|
|
1003
|
+
}
|
|
1004
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1005
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
1006
|
+
let tmpSuccess = (tmpStatus >= 200 && tmpStatus < 300);
|
|
1007
|
+
if (!tmpSuccess)
|
|
1008
|
+
{
|
|
1009
|
+
return fResolve({ Available: true, Success: false, Status: tmpStatus, Reason: `PUT returned ${tmpStatus}` });
|
|
1010
|
+
}
|
|
1011
|
+
return fResolve({ Available: true, Success: true });
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ============== Schema bootstrap state machine ==============
|
|
1017
|
+
|
|
1018
|
+
_handleMeadowProxyBootstrap(pBeaconID)
|
|
1019
|
+
{
|
|
1020
|
+
if (!pBeaconID) return;
|
|
1021
|
+
if (!this._SchemaDescriptor) return;
|
|
1022
|
+
if (this._BootstrappedBeacons.has(pBeaconID)) return;
|
|
1023
|
+
if (this._BootstrapInFlight.has(pBeaconID)) return;
|
|
1024
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
1025
|
+
if (!tmpAssigned || tmpAssigned.BeaconID !== pBeaconID) return;
|
|
1026
|
+
this._BootstrapInFlight.add(pBeaconID);
|
|
1027
|
+
this._runBootstrap(pBeaconID, tmpAssigned.IDBeaconConnection)
|
|
1028
|
+
.then((pResult) =>
|
|
1029
|
+
{
|
|
1030
|
+
this._BootstrapInFlight.delete(pBeaconID);
|
|
1031
|
+
if (pResult && pResult.Success)
|
|
1032
|
+
{
|
|
1033
|
+
this._BootstrappedBeacons.add(pBeaconID);
|
|
1034
|
+
this._BootstrappedAt = new Date().toISOString();
|
|
1035
|
+
this._LastBootstrapError = null;
|
|
1036
|
+
this.log.info(`ManifestStoreBridge: MeadowProxy bootstrap complete for beacon [${pBeaconID}].`);
|
|
1037
|
+
}
|
|
1038
|
+
else
|
|
1039
|
+
{
|
|
1040
|
+
this._LastBootstrapError = (pResult && pResult.Reason) || 'Unknown bootstrap error';
|
|
1041
|
+
this.log.warn(`ManifestStoreBridge: MeadowProxy bootstrap failed for beacon [${pBeaconID}]: ${this._LastBootstrapError}`);
|
|
1042
|
+
}
|
|
1043
|
+
})
|
|
1044
|
+
.catch((pErr) =>
|
|
1045
|
+
{
|
|
1046
|
+
this._BootstrapInFlight.delete(pBeaconID);
|
|
1047
|
+
this._LastBootstrapError = (pErr && pErr.message) || String(pErr);
|
|
1048
|
+
this.log.warn(`ManifestStoreBridge: MeadowProxy bootstrap threw for beacon [${pBeaconID}]: ${this._LastBootstrapError}`);
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async _runBootstrap(pBeaconID, pIDBeaconConnection)
|
|
1053
|
+
{
|
|
1054
|
+
let tmpCoord = this._coord();
|
|
1055
|
+
if (!tmpCoord) return { Success: false, Reason: 'BeaconCoordinator not available' };
|
|
1056
|
+
|
|
1057
|
+
let tmpEnsure = await this._mestDispatch(tmpCoord, 'DataBeaconSchema', 'EnsureSchema',
|
|
1058
|
+
{
|
|
1059
|
+
IDBeaconConnection: pIDBeaconConnection,
|
|
1060
|
+
SchemaName: this._SchemaDescriptor.SchemaName || 'ultravisor',
|
|
1061
|
+
SchemaJSON: this._SchemaDescriptor
|
|
1062
|
+
});
|
|
1063
|
+
if (!tmpEnsure.Success)
|
|
1064
|
+
{
|
|
1065
|
+
return { Success: false, Reason: `EnsureSchema failed: ${tmpEnsure.Reason}` };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Introspect populates the databeacon's IntrospectedTable cache
|
|
1069
|
+
// so subsequent EnableEndpoint calls find the columns for our
|
|
1070
|
+
// freshly-created UV* tables.
|
|
1071
|
+
let tmpIntrospect = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'Introspect',
|
|
1072
|
+
{
|
|
1073
|
+
IDBeaconConnection: pIDBeaconConnection
|
|
1074
|
+
});
|
|
1075
|
+
if (!tmpIntrospect.Success)
|
|
1076
|
+
{
|
|
1077
|
+
return { Success: false, Reason: `Introspect failed: ${tmpIntrospect.Reason}` };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
let tmpProxy = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'UpdateProxyConfig',
|
|
1081
|
+
{
|
|
1082
|
+
PathAllowlist: UV_PROXY_PATH_PATTERNS
|
|
1083
|
+
});
|
|
1084
|
+
if (!tmpProxy.Success)
|
|
1085
|
+
{
|
|
1086
|
+
return { Success: false, Reason: `UpdateProxyConfig failed: ${tmpProxy.Reason}` };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
this._EndpointBaseByBeacon[pBeaconID] = this._EndpointBaseByBeacon[pBeaconID] || {};
|
|
1090
|
+
for (let i = 0; i < MANIFEST_TABLES.length; i++)
|
|
1091
|
+
{
|
|
1092
|
+
let tmpTable = MANIFEST_TABLES[i];
|
|
1093
|
+
let tmpEnable = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'EnableEndpoint',
|
|
1094
|
+
{
|
|
1095
|
+
IDBeaconConnection: pIDBeaconConnection,
|
|
1096
|
+
TableName: tmpTable
|
|
1097
|
+
});
|
|
1098
|
+
if (!tmpEnable.Success)
|
|
1099
|
+
{
|
|
1100
|
+
return { Success: false, Reason: `EnableEndpoint(${tmpTable}) failed: ${tmpEnable.Reason}` };
|
|
1101
|
+
}
|
|
1102
|
+
let tmpBase = (tmpEnable.Outputs && tmpEnable.Outputs.EndpointBase) || `/1.0/${tmpTable}`;
|
|
1103
|
+
this._EndpointBaseByBeacon[pBeaconID][tmpTable] = tmpBase;
|
|
1104
|
+
}
|
|
1105
|
+
return { Success: true };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
_mestDispatch(pCoord, pCapability, pAction, pSettings)
|
|
1109
|
+
{
|
|
1110
|
+
return new Promise((fResolve) =>
|
|
1111
|
+
{
|
|
1112
|
+
pCoord.dispatchAndWait(
|
|
1113
|
+
{
|
|
1114
|
+
Capability: pCapability,
|
|
1115
|
+
Action: pAction,
|
|
1116
|
+
Settings: pSettings || {},
|
|
1117
|
+
AffinityKey: 'manifest-store-bootstrap',
|
|
1118
|
+
TimeoutMs: this._TimeoutMs * 4
|
|
1119
|
+
},
|
|
1120
|
+
(pError, pResult) =>
|
|
1121
|
+
{
|
|
1122
|
+
if (pError)
|
|
1123
|
+
{
|
|
1124
|
+
return fResolve({ Success: false, Reason: pError.message || String(pError) });
|
|
1125
|
+
}
|
|
1126
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1127
|
+
let tmpSuccess = (tmpOut.Success !== false);
|
|
1128
|
+
return fResolve({ Success: tmpSuccess, Reason: tmpOut.Reason || '', Outputs: tmpOut });
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
module.exports = UltravisorManifestStoreBridge;
|