@yarkivaev/scada 2.3.116 → 2.4.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.
- package/README.md +90 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +11 -5
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -2
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +12 -2
- package/src/infrastructure/opcua/session.js +20 -10
- package/src/infrastructure/opcua/source.js +6 -3
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +105 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +1 -1
package/README.md
CHANGED
|
@@ -395,3 +395,93 @@ before applying the replacement. This trades a collection gap for no overlap.
|
|
|
395
395
|
The caller must bind each selector to its old Deployment; no namespace-wide prune
|
|
396
396
|
is performed. Render/check/diff do not delete anything. An optional process `image`
|
|
397
397
|
overrides the release image, so unrelated collectors can retain their pinned runtime.
|
|
398
|
+
|
|
399
|
+
## Universal collector runtime
|
|
400
|
+
|
|
401
|
+
`scada-collector manifest.json` (or `scada-collector -` for stdin) runs all
|
|
402
|
+
registered adapters inside one Node.js process. Every instance has the same
|
|
403
|
+
registry; assignments select its work. `COLLECTOR_MANIFEST` accepts inline JSON;
|
|
404
|
+
`COLLECTOR_MANIFEST_PATH` selects a file. SIGHUP reconciles the file, SIGTERM/SIGINT
|
|
405
|
+
perform bounded shutdown. No cluster scheduler, lease service or network control
|
|
406
|
+
API is included. HTTP request handlers and their process-local read models remain
|
|
407
|
+
owned by the HTTP application; use `COLLECTOR_MODE=external` to disable its
|
|
408
|
+
transferable background collection before assigning that work elsewhere.
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
import { createRegistry, createRuntime } from '@yarkivaev/scada/runtime';
|
|
412
|
+
const runtime = createRuntime({ registry: createRegistry() });
|
|
413
|
+
await runtime.apply({ taskId: 'line-reader', generation: 1, specVersion: 1,
|
|
414
|
+
spec: { adapter: 'modbus', url: 'mqtt://broker', config: {
|
|
415
|
+
addresses: ['tcp://127.0.0.1:502'], streams: [], maps: [], interval: 5
|
|
416
|
+
} } });
|
|
417
|
+
await runtime.remove({ taskId: 'line-reader', generation: 2 });
|
|
418
|
+
await runtime.stop();
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Adapters include `stream` (registered source/transform/sinks), `modbus`,
|
|
422
|
+
`activity`, `amqp-relay`, `amqp-metrics`, `mqtt-metrics`, `operation-sync`,
|
|
423
|
+
`amqp-pg-metrics`, `silent-streams` and `stomp-ingest`. Plant packages extend the
|
|
424
|
+
registry with domain-specific adapters; OPC UA belongs here, not in
|
|
425
|
+
source-to-sink. Constructors do not open connections. A task owns independent
|
|
426
|
+
connections and must close them in `stop()`. Factories are trusted code, not
|
|
427
|
+
arbitrary scripts supplied in manifests.
|
|
428
|
+
|
|
429
|
+
Assignments have stable task IDs, externally supplied integer generations and
|
|
430
|
+
`specVersion: 1`. An optional `specHash` is checked against canonical JSON.
|
|
431
|
+
Identical repeats are idempotent; conflicting/stale generations are rejected.
|
|
432
|
+
Replacement waits for the previous owner to stop. Removed IDs retain bounded
|
|
433
|
+
in-memory tombstones. Cleanup timeout quarantines resources and marks the
|
|
434
|
+
runtime fatal; it never authorizes a second owner. Generation and PostgreSQL CAS
|
|
435
|
+
are local/conflict safeguards, **not distributed fencing**. The external agent
|
|
436
|
+
must establish cross-node ownership and provide current assignments after restart.
|
|
437
|
+
|
|
438
|
+
Secrets can be references such as `{ "$secret": "AMQP_URL" }`, resolved by the
|
|
439
|
+
host immediately before adapter construction. Status excludes configuration and
|
|
440
|
+
exception messages. Optional `COLLECTOR_HEALTH_PORT` and
|
|
441
|
+
`COLLECTOR_HEALTH_HOST` (default `127.0.0.1`) expose read-only `/live`, `/ready`
|
|
442
|
+
and `/status`. Readiness represents admission, not the health of every remote
|
|
443
|
+
server. Fatal cleanup makes liveness fail. A blocked event loop cannot answer
|
|
444
|
+
an external probe; synchronous infinite loops/native crashes still affect the
|
|
445
|
+
entire process. No hard CPU or native-memory isolation is claimed.
|
|
446
|
+
|
|
447
|
+
The process defaults to 16 MiB / 16,384 buffered records, each task to 1 MiB /
|
|
448
|
+
1,024, with bounded control admission and start/stop deadlines. Adapter-owned
|
|
449
|
+
buffers and actual sink promises reserve capacity; driver/internal allocations
|
|
450
|
+
and transient JSON decoding are not a hard RSS limit. Configure capacities for
|
|
451
|
+
all simultaneously retained pipeline stages. MQTT and Loki collectors flush
|
|
452
|
+
individual accepted records because their source delivery is sequential;
|
|
453
|
+
AMQP pipelines retain bounded timed batching. Timeout is not proof that a remote
|
|
454
|
+
side effect did not happen.
|
|
455
|
+
|
|
456
|
+
`stream` requires explicit `delivery: "live" | "durable"`. Durable streams need
|
|
457
|
+
`stateVersion`, `compatibilityKey`, stable `eventId` and a shared state store.
|
|
458
|
+
Durable payloads and checkpoints use JSON; encode binary explicitly as strings.
|
|
459
|
+
They persist an outbox and each recipient acknowledgement before advancing the
|
|
460
|
+
checkpoint. MQTT requires QoS 1/2; AMQP sets persistent/mandatory publication and
|
|
461
|
+
checks returned messages. Broker confirmation does not prove downstream business
|
|
462
|
+
processing. An uncertain send can repeat with the same event ID; receivers must
|
|
463
|
+
deduplicate. Sources with a non-null checkpoint must implement `restore()`;
|
|
464
|
+
unsupported restoration fails closed. Live telemetry cannot replay downtime.
|
|
465
|
+
Loki fails a saturated page rather than silently advancing across truncated data.
|
|
466
|
+
|
|
467
|
+
The runner accepts `COLLECTOR_STATE_URL` for PostgreSQL. Apply
|
|
468
|
+
`db/migrations/V0009__collector_task_state.sql` explicitly and grant the service
|
|
469
|
+
role access before starting stateful assignments; the runner never runs schema
|
|
470
|
+
migrations or falls back to local disk. The storage must be reachable from every
|
|
471
|
+
eligible node. The built-in store rejects unsupported ownership tokens rather
|
|
472
|
+
than pretending to enforce an external fencing protocol.
|
|
473
|
+
|
|
474
|
+
The coordinated review branches require new source-to-sink/scada package releases
|
|
475
|
+
before dependency installation can reproduce all runtime tests. Stable release/pin
|
|
476
|
+
updates and deployment remain separate steps. Unit, transport,
|
|
477
|
+
state recovery and mixed-runtime tests do not constitute a 24-hour production
|
|
478
|
+
soak or prove cluster-wide exactly-once effects.
|
|
479
|
+
|
|
480
|
+
## Streaming package boundary
|
|
481
|
+
|
|
482
|
+
`@yarkivaev/source-to-sink` 2.x contains only protocol-independent composition
|
|
483
|
+
primitives with no runtime dependencies. Protocol/storage implementations and
|
|
484
|
+
their integration tests live in `src/infrastructure/streams` and are exported as
|
|
485
|
+
`@yarkivaev/scada/streams`. This includes AMQP, MQTT, STOMP, serial, Modbus, Loki,
|
|
486
|
+
PostgreSQL, ClickHouse and SQLite; OPC UA stays in `@yarkivaev/scada/opcua`.
|
|
487
|
+
Plant-specific transformations stay in the plant package.
|
|
@@ -58,6 +58,28 @@ spec:
|
|
|
58
58
|
image: {{ required "process image is required" (default $.Values.image .image) | quote }}
|
|
59
59
|
imagePullPolicy: IfNotPresent
|
|
60
60
|
command: {{ required "process command is required" .command | toJson }}
|
|
61
|
+
{{- with .health }}
|
|
62
|
+
ports:
|
|
63
|
+
- name: health
|
|
64
|
+
containerPort: {{ .port }}
|
|
65
|
+
protocol: TCP
|
|
66
|
+
livenessProbe:
|
|
67
|
+
httpGet:
|
|
68
|
+
path: {{ default "/live" .livenessPath | quote }}
|
|
69
|
+
port: health
|
|
70
|
+
initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
|
|
71
|
+
periodSeconds: {{ default 10 .periodSeconds }}
|
|
72
|
+
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
73
|
+
failureThreshold: {{ default 3 .failureThreshold }}
|
|
74
|
+
readinessProbe:
|
|
75
|
+
httpGet:
|
|
76
|
+
path: {{ default "/ready" .readinessPath | quote }}
|
|
77
|
+
port: health
|
|
78
|
+
initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
|
|
79
|
+
periodSeconds: {{ default 10 .periodSeconds }}
|
|
80
|
+
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
81
|
+
failureThreshold: {{ default 3 .failureThreshold }}
|
|
82
|
+
{{- end }}
|
|
61
83
|
{{- if .hostDevices }}
|
|
62
84
|
securityContext:
|
|
63
85
|
privileged: true
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
"name": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" },
|
|
12
12
|
"command": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
|
13
13
|
"env": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
|
14
|
+
"health": {
|
|
15
|
+
"type": "object", "required": ["port"], "additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
|
|
18
|
+
"livenessPath": { "type": "string", "pattern": "^/" },
|
|
19
|
+
"readinessPath": { "type": "string", "pattern": "^/" },
|
|
20
|
+
"initialDelaySeconds": { "type": "integer", "minimum": 0 },
|
|
21
|
+
"periodSeconds": { "type": "integer", "minimum": 1 },
|
|
22
|
+
"timeoutSeconds": { "type": "integer", "minimum": 1 },
|
|
23
|
+
"failureThreshold": { "type": "integer", "minimum": 1 }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
14
26
|
"hostDevices": {
|
|
15
27
|
"type": "array", "uniqueItems": true,
|
|
16
28
|
"items": { "type": "string", "pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$" }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
"./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
|
|
18
18
|
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js",
|
|
19
19
|
"./opcua": "./src/infrastructure/opcua/index.js",
|
|
20
|
-
"./opcua/config": "./src/infrastructure/opcua/config.js"
|
|
20
|
+
"./opcua/config": "./src/infrastructure/opcua/config.js",
|
|
21
|
+
"./runtime": "./src/application/runtime/index.js",
|
|
22
|
+
"./streams": "./src/infrastructure/streams/index.js"
|
|
21
23
|
},
|
|
22
24
|
"bin": {
|
|
23
25
|
"modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
|
|
24
|
-
"supervisor-sink": "./src/bin/supervisor-sink.js"
|
|
26
|
+
"supervisor-sink": "./src/bin/supervisor-sink.js",
|
|
27
|
+
"scada-collector": "./src/bin/collector.js"
|
|
25
28
|
},
|
|
26
29
|
"scripts": {
|
|
27
30
|
"test": "mocha --exit 'test/**/test*.js'",
|
|
@@ -35,16 +38,19 @@
|
|
|
35
38
|
"@clickhouse/client": "^1.0.0",
|
|
36
39
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
37
40
|
"@yarkivaev/simple-server": "1.0.2",
|
|
38
|
-
"@yarkivaev/source-to-sink": "
|
|
41
|
+
"@yarkivaev/source-to-sink": "2.0.0",
|
|
39
42
|
"amqplib": "^0.10.9",
|
|
43
|
+
"better-sqlite3": "^11.0.0",
|
|
44
|
+
"modbus-serial": "^8.0.0",
|
|
45
|
+
"mqtt": "^5.0.0",
|
|
40
46
|
"node-opcua": "2.179.0",
|
|
41
47
|
"pg": "^8.0.0",
|
|
48
|
+
"serialport": "^12.0.0",
|
|
42
49
|
"stompit": "^1.0.0",
|
|
43
50
|
"zod": "^4.6.7"
|
|
44
51
|
},
|
|
45
52
|
"devDependencies": {
|
|
46
53
|
"@eslint/js": "^9.39.2",
|
|
47
|
-
"better-sqlite3": "^11.0.0",
|
|
48
54
|
"c8": "^10.1.3",
|
|
49
55
|
"cassandra-driver": "^4.7.2",
|
|
50
56
|
"eslint": "^9.39.2",
|
|
@@ -4,10 +4,13 @@ import applyIncludes from './applyIncludes.js';
|
|
|
4
4
|
* Parses one scalar token.
|
|
5
5
|
*
|
|
6
6
|
* @param {string} text - raw scalar
|
|
7
|
-
* @returns {string|number|boolean|null} scalar
|
|
7
|
+
* @returns {string|number|boolean|null|Array|object} scalar
|
|
8
8
|
*/
|
|
9
9
|
function scalar(text) {
|
|
10
10
|
const trimmed = text.trim();
|
|
11
|
+
if (trimmed === '[]' || trimmed === '{}') {
|
|
12
|
+
return trimmed === '[]' ? [] : {};
|
|
13
|
+
}
|
|
11
14
|
if (trimmed === 'true') {
|
|
12
15
|
return true;
|
|
13
16
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
-
import { stompSource } from '
|
|
2
|
+
import { stompSource } from '../infrastructure/streams/index.js';
|
|
3
3
|
import plantApi from './plantApi.js';
|
|
4
4
|
import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
|
|
5
5
|
import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { clickhouseSink } from '../../infrastructure/streams/index.js';
|
|
2
|
+
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
3
|
+
import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
|
|
4
|
+
import operationsFromPg from '../../infrastructure/persistence/pg/operations.js';
|
|
5
|
+
import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
|
|
6
|
+
import postgresPool from '../../infrastructure/persistence/postgresPool.js';
|
|
7
|
+
import foldedMetricsSink from '../foldedMetricsSink.js';
|
|
8
|
+
import silentStreams from '../../infrastructure/ingest/modbus/silentStreams.js';
|
|
9
|
+
import mx210Tcp from '../../infrastructure/ingest/modbus/mx210Tcp.js';
|
|
10
|
+
import segmentPipeline from '../../infrastructure/ingest/pipelines/segmentPipeline.js';
|
|
11
|
+
import decisionPipeline from '../../infrastructure/ingest/pipelines/decisionPipeline.js';
|
|
12
|
+
import alertPipeline from '../../infrastructure/ingest/pipelines/alertPipeline.js';
|
|
13
|
+
import resolveSecrets from './resolveSecrets.js';
|
|
14
|
+
|
|
15
|
+
function urlOptions(spec) {
|
|
16
|
+
const protocols = { amqp: ['amqp:', 'amqps:'], stomp: ['stomp:', 'stomps:'],
|
|
17
|
+
postgres: ['postgres:', 'postgresql:'], clickhouse: ['http:', 'https:'] };
|
|
18
|
+
Object.entries(protocols).forEach(([key, allowed]) => {
|
|
19
|
+
const value = spec[key];
|
|
20
|
+
if (value === undefined || value?.$secret) { return; }
|
|
21
|
+
if (typeof value !== 'string' || !allowed.includes(new URL(value).protocol)) { throw new TypeError(`Invalid ${key} URL`); }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function boundedOptions(spec) {
|
|
25
|
+
const maxRecords = Math.min(spec.options?.maxRecords ?? 1000, spec.limits?.maxRecords ?? 1024);
|
|
26
|
+
const maxBytes = Math.min(spec.options?.maxBytes ?? 1048576, spec.limits?.maxBytes ?? 1048576);
|
|
27
|
+
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
28
|
+
throw new TypeError('Ingest limits must be positive safe integers');
|
|
29
|
+
}
|
|
30
|
+
return { ...spec.options, maxRecords, maxBytes, maxMessageBytes: maxBytes, maxPendingBytes: maxBytes, size: Math.min(spec.options?.size ?? 100, maxRecords),
|
|
31
|
+
prefetch: Math.min(spec.options?.prefetch ?? 32, maxRecords) };
|
|
32
|
+
}
|
|
33
|
+
function ownedSink(pipeline, sink) {
|
|
34
|
+
return { start() { return pipeline.start(); }, async stop() {
|
|
35
|
+
try { await pipeline.stop(); } finally { await sink.stop?.(); }
|
|
36
|
+
} };
|
|
37
|
+
}
|
|
38
|
+
function together(items) {
|
|
39
|
+
return {
|
|
40
|
+
start() { return Promise.all(items.map((item) => { return item.start(); })); },
|
|
41
|
+
async stop() {
|
|
42
|
+
const results = await Promise.allSettled(items.map((item) => { return item.stop(); }));
|
|
43
|
+
const failure = results.find((result) => { return result.status === 'rejected'; });
|
|
44
|
+
if (failure) { throw failure.reason; }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function borrowed(pool) {
|
|
49
|
+
return { query(...args) { return pool.query(...args); }, connect() { return pool.connect(); },
|
|
50
|
+
end() { return Promise.resolve(); } };
|
|
51
|
+
}
|
|
52
|
+
function telemetry(spec, pool) {
|
|
53
|
+
const inner = spec.clickhouse ? clickhouseSink(spec.clickhouse, spec.table ?? 'scada.metrics') : metricsSinkFromPool(pool);
|
|
54
|
+
return foldedMetricsSink(inner, pool, spec.devices);
|
|
55
|
+
}
|
|
56
|
+
function metrics(spec, pool, context) {
|
|
57
|
+
const sink = telemetry(spec, pool);
|
|
58
|
+
return ownedSink(amqpMetricsIngest(spec.amqp, spec.queue, sink, { ...boundedOptions(spec), budget: context.budget }), sink);
|
|
59
|
+
}
|
|
60
|
+
function silent(spec, pool, context) {
|
|
61
|
+
const sink = telemetry(spec, pool);
|
|
62
|
+
const streams = silentStreams({ budget: spec.budget, interval: spec.interval,
|
|
63
|
+
sources: spec.sources.map((source) => { return mx210Tcp(source.name, source.host, source.port); }),
|
|
64
|
+
onError() { context.report?.({ state: 'degraded' }); } });
|
|
65
|
+
streams.bind(sink);
|
|
66
|
+
const ingest = amqpMetricsIngest(spec.amqp, spec.queue, sink, {
|
|
67
|
+
...boundedOptions(spec), budget: context.budget, onSeen(name) { streams.seen(name); }
|
|
68
|
+
});
|
|
69
|
+
return ownedSink(together([ingest, streams]), sink);
|
|
70
|
+
}
|
|
71
|
+
function stomp(spec, pool, context) {
|
|
72
|
+
const shared = borrowed(pool);
|
|
73
|
+
const config = { ...spec.config, ...boundedOptions({ ...spec, options: spec.config }), budget: context.budget };
|
|
74
|
+
return together([
|
|
75
|
+
segmentPipeline(spec.stomp, spec.postgres, shared, config),
|
|
76
|
+
decisionPipeline(spec.stomp, shared, config),
|
|
77
|
+
alertPipeline(spec.stomp, shared, config)
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
function close(state) {
|
|
81
|
+
state.closing ??= Promise.resolve().then(async () => {
|
|
82
|
+
try { await state.pipeline?.stop(); } finally { await state.pool?.end(); }
|
|
83
|
+
});
|
|
84
|
+
return state.closing;
|
|
85
|
+
}
|
|
86
|
+
function task(factory, spec, context) {
|
|
87
|
+
const state = { pipeline: null, pool: null, stopped: false };
|
|
88
|
+
return {
|
|
89
|
+
async start() {
|
|
90
|
+
if (state.stopped) { throw new Error('Ingest task stopped'); }
|
|
91
|
+
context.signal.throwIfAborted();
|
|
92
|
+
const resolved = resolveSecrets(spec, context.secrets);
|
|
93
|
+
urlOptions(resolved);
|
|
94
|
+
state.pool = resolved.postgres ? postgresPool({ connectionString: resolved.postgres }) : null;
|
|
95
|
+
try {
|
|
96
|
+
state.pipeline = factory(resolved, state.pool, context);
|
|
97
|
+
await state.pipeline.start();
|
|
98
|
+
context.signal.throwIfAborted();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await close(state);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async stop() {
|
|
105
|
+
state.stopped = true;
|
|
106
|
+
await close(state);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function required(spec, keys) {
|
|
111
|
+
keys.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing ingest ${key}`); } });
|
|
112
|
+
}
|
|
113
|
+
function silentConfig(spec) {
|
|
114
|
+
if (!spec.clickhouse && !spec.postgres) { throw new TypeError('Missing silent metrics storage'); }
|
|
115
|
+
if (spec.devices && Object.keys(spec.devices).length && !spec.postgres) { throw new TypeError('Missing folding postgres'); }
|
|
116
|
+
if (![spec.budget, spec.interval].every((value) => { return Number.isFinite(value) && value > 0; })) {
|
|
117
|
+
throw new TypeError('Silent budget and interval must be positive');
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(spec.sources) || !spec.sources.length || spec.sources.some((source) => {
|
|
120
|
+
return !source.name || !source.host || !Number.isInteger(source.port) || source.port < 1 || source.port > 65535;
|
|
121
|
+
})) { throw new TypeError('Invalid silent source'); }
|
|
122
|
+
}
|
|
123
|
+
function descriptor(factory, keys, validate = () => { return undefined; }, resources = () => { return []; }) {
|
|
124
|
+
return { validate(spec) { required(spec, keys); urlOptions(spec); boundedOptions(spec); validate(spec); }, resources,
|
|
125
|
+
create(spec, context) { return task(factory, spec, context); } };
|
|
126
|
+
}
|
|
127
|
+
/** Portable consumers previously embedded in site HTTP processes; migrations remain external. */
|
|
128
|
+
export default function additionalAdapters() {
|
|
129
|
+
return new Map([
|
|
130
|
+
['operation-sync', descriptor((spec, pool, context) => {
|
|
131
|
+
return operationSyncIngest(spec.amqp, spec.queue, operationsFromPg(pool), { ...boundedOptions(spec), budget: context.budget });
|
|
132
|
+
}, ['amqp', 'queue', 'postgres'])],
|
|
133
|
+
['amqp-pg-metrics', descriptor(metrics, ['amqp', 'queue', 'postgres'])],
|
|
134
|
+
['silent-streams', descriptor(silent, ['amqp', 'queue', 'sources'], silentConfig, (spec) => {
|
|
135
|
+
return [...new Set(spec.sources.map((source) => { return `modbus-tcp:${source.host}:${source.port}`; }))];
|
|
136
|
+
})],
|
|
137
|
+
['stomp-ingest', descriptor(stomp, ['stomp', 'postgres', 'config'])]
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function canonical(value) {
|
|
4
|
+
if (Array.isArray(value)) { return value.map(canonical); }
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => { return [key, canonical(value[key])]; }));
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function identity(input) {
|
|
11
|
+
if (!input || typeof input.taskId !== 'string' || !/^[\w./:-]{1,200}$/u.test(input.taskId)) {
|
|
12
|
+
throw new TypeError('Invalid taskId');
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isSafeInteger(input.generation) || input.generation < 0) { throw new TypeError('Invalid generation'); }
|
|
15
|
+
}
|
|
16
|
+
export default function assignment(input, registry) {
|
|
17
|
+
identity(input);
|
|
18
|
+
if (input.specVersion !== 1) { throw new TypeError('Unsupported specVersion'); }
|
|
19
|
+
const json = JSON.stringify(input.spec);
|
|
20
|
+
if (!json || Buffer.byteLength(json) > 1048576) { throw new TypeError('Invalid or oversized task spec'); }
|
|
21
|
+
const spec = JSON.parse(json);
|
|
22
|
+
Object.entries(spec.limits ?? {}).forEach(([key, value]) => {
|
|
23
|
+
if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`Invalid ${key}`); }
|
|
24
|
+
});
|
|
25
|
+
const adapter = registry.get(spec.adapter);
|
|
26
|
+
if (!adapter) { throw new TypeError('Unknown task adapter'); }
|
|
27
|
+
const hash = createHash('sha256').update(JSON.stringify(canonical(spec))).digest('hex');
|
|
28
|
+
if (input.specHash && input.specHash !== hash) { throw new TypeError('Invalid specHash'); }
|
|
29
|
+
return { ...input, spec, specHash: hash, adapter };
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Reservation follows the driver's actual promise, not an outer timeout race. */
|
|
2
|
+
export default function budgetedSink(sink, budget, countRecords = true) {
|
|
3
|
+
return {
|
|
4
|
+
start() { return sink.start?.(); },
|
|
5
|
+
stop() { return sink.stop?.(); },
|
|
6
|
+
write(records, options) {
|
|
7
|
+
try {
|
|
8
|
+
options?.signal?.throwIfAborted();
|
|
9
|
+
const release = budget.reserve({ bytes: Buffer.byteLength(JSON.stringify(records)), records: countRecords ? records.length : 0 });
|
|
10
|
+
return Promise.resolve().then(() => {
|
|
11
|
+
options?.signal?.throwIfAborted();
|
|
12
|
+
return sink.write(records, options);
|
|
13
|
+
}).finally(release);
|
|
14
|
+
} catch (error) { return Promise.reject(error); }
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Only the executable runner owns process-global signal handlers. */
|
|
2
|
+
export default function collectorSignals(collector) {
|
|
3
|
+
function report(error) { process.stderr.write(`collector: ${error.name}\n`); }
|
|
4
|
+
function stop() { void collector.stop().then(() => { process.exit(0); }, () => { process.exit(1); }); }
|
|
5
|
+
function reload() { void collector.reload().catch(report); }
|
|
6
|
+
process.once('SIGTERM', stop);
|
|
7
|
+
process.once('SIGINT', stop);
|
|
8
|
+
process.on('SIGHUP', reload);
|
|
9
|
+
return () => {
|
|
10
|
+
process.off('SIGTERM', stop);
|
|
11
|
+
process.off('SIGINT', stop);
|
|
12
|
+
process.off('SIGHUP', reload);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
async function close(state) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
for (const part of [...state.opened].reverse()) {
|
|
4
|
+
try {
|
|
5
|
+
// Reverse startup order; every owned resource receives a stop attempt.
|
|
6
|
+
// eslint-disable-next-line no-await-in-loop
|
|
7
|
+
await part.stop();
|
|
8
|
+
} catch (error) { failures.push(error); }
|
|
9
|
+
}
|
|
10
|
+
if (failures.length) { throw new AggregateError(failures, 'Composite cleanup failed'); }
|
|
11
|
+
state.opened = [];
|
|
12
|
+
}
|
|
13
|
+
function cleanup(state) {
|
|
14
|
+
state.closing ??= close(state);
|
|
15
|
+
return state.closing;
|
|
16
|
+
}
|
|
17
|
+
async function open(state, parts) {
|
|
18
|
+
try {
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (state.stopped) { throw new Error('Composite startup aborted'); }
|
|
21
|
+
state.opened.push(part);
|
|
22
|
+
// Register ownership before awaiting potentially cancellable startup.
|
|
23
|
+
// eslint-disable-next-line no-await-in-loop
|
|
24
|
+
await part.start();
|
|
25
|
+
if (state.stopped) { throw new Error('Composite startup aborted'); }
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
try { await cleanup(state); } catch (failure) { throw new AggregateError([error, failure], 'Start and cleanup failed'); }
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Stop attempts cancellation immediately, then waits for startup to settle. */
|
|
33
|
+
export default function compositeLifecycle({ inputs, outputs }) {
|
|
34
|
+
const state = { opened: [], starting: null, closing: null, stopping: null, stopped: false };
|
|
35
|
+
return {
|
|
36
|
+
start() {
|
|
37
|
+
if (state.stopped) { return Promise.reject(new Error('Composite stopped')); }
|
|
38
|
+
state.starting ??= open(state, [...outputs, ...inputs]);
|
|
39
|
+
return state.starting;
|
|
40
|
+
},
|
|
41
|
+
stop() {
|
|
42
|
+
state.stopped = true;
|
|
43
|
+
state.stopping ??= Promise.all([cleanup(state), Promise.resolve(state.starting).catch(() => {
|
|
44
|
+
// Startup failure remains visible to its own caller.
|
|
45
|
+
})]);
|
|
46
|
+
return state.stopping;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resources, sinks, sources } from './streamPorts.js';
|
|
2
|
+
import pipelineAdapters from './pipelineAdapters.js';
|
|
3
|
+
import additionalAdapters from './additionalAdapters.js';
|
|
4
|
+
import streamTask from './streamTask.js';
|
|
5
|
+
|
|
6
|
+
function extend(base, additions = new Map()) {
|
|
7
|
+
additions.forEach((value, key) => {
|
|
8
|
+
if (base.has(key)) { throw new Error(`duplicate runtime adapter: ${key}`); }
|
|
9
|
+
base.set(key, value);
|
|
10
|
+
});
|
|
11
|
+
return base;
|
|
12
|
+
}
|
|
13
|
+
/** Same protocol capabilities on every executor; only registered extensions may run. */
|
|
14
|
+
export default function createRegistry(options = {}) {
|
|
15
|
+
const ports = { sources: extend(sources(), options.sources), sinks: extend(sinks(), options.sinks), transforms: options.transforms ?? new Map() };
|
|
16
|
+
const stream = streamTask(ports);
|
|
17
|
+
const registry = extend(pipelineAdapters(), additionalAdapters());
|
|
18
|
+
registry.set('stream', { ...stream, resources(spec) { return [...new Set([...resources(spec), ...(spec.resources ?? [])])]; } });
|
|
19
|
+
return extend(registry, options.adapters);
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bounds waiting; callers must also abort/close resources and fence late results. */
|
|
2
|
+
export default function deadline(action, milliseconds) {
|
|
3
|
+
let timer;
|
|
4
|
+
const timeout = new Promise((resolve, reject) => {
|
|
5
|
+
timer = setTimeout(() => { reject(new Error('Task operation timed out')); }, milliseconds);
|
|
6
|
+
});
|
|
7
|
+
return Promise.race([Promise.resolve().then(action), timeout]).finally(() => { clearTimeout(timer); });
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as createRuntime } from './runtime.js';
|
|
2
|
+
export { default as taskBudget } from './taskBudget.js';
|
|
3
|
+
export { default as taskState } from './taskState.js';
|
|
4
|
+
export { default as streamTask } from './streamTask.js';
|
|
5
|
+
export { default as compositeLifecycle } from './compositeLifecycle.js';
|
|
6
|
+
export { default as runCollector } from './runCollector.js';
|
|
7
|
+
export { default as taskQueue } from './taskQueue.js';
|
|
8
|
+
export { default as createRegistry } from './createRegistry.js';
|
|
9
|
+
export { default as resolveSecrets } from './resolveSecrets.js';
|
|
10
|
+
export { default as taskStatePg } from '../../infrastructure/persistence/runtime/taskStatePg.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import assignment, { identity } from './assignment.js';
|
|
3
|
+
|
|
4
|
+
async function read(stream) {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
let bytes = 0;
|
|
7
|
+
for await (const chunk of stream) {
|
|
8
|
+
bytes += Buffer.byteLength(chunk);
|
|
9
|
+
if (bytes > 1048576) { throw new Error('Manifest capacity exceeded'); }
|
|
10
|
+
chunks.push(Buffer.from(chunk));
|
|
11
|
+
}
|
|
12
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
13
|
+
}
|
|
14
|
+
export async function readManifest(env, argv) {
|
|
15
|
+
const text = env.COLLECTOR_MANIFEST ?? await read(argv[0] === '-' ? process.stdin : createReadStream(argv[0] ?? env.COLLECTOR_MANIFEST_PATH));
|
|
16
|
+
if (Buffer.byteLength(text) > 1048576) { throw new Error('Manifest capacity exceeded'); }
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
export async function validateManifest(document, registry) {
|
|
20
|
+
if (!document || !Array.isArray(document.assignments) || !Array.isArray(document.removals ?? [])) {
|
|
21
|
+
throw new TypeError('Manifest requires assignments and optional removals arrays');
|
|
22
|
+
}
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
const resources = new Set();
|
|
25
|
+
for (const input of [...document.assignments, ...(document.removals ?? [])]) {
|
|
26
|
+
identity(input);
|
|
27
|
+
if (ids.has(input.taskId)) { throw new TypeError('Duplicate manifest taskId'); }
|
|
28
|
+
ids.add(input.taskId);
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(document.assignments.map(async (input) => {
|
|
31
|
+
const request = assignment(input, registry);
|
|
32
|
+
await request.adapter.validate(request.spec);
|
|
33
|
+
request.adapter.resources(request.spec).forEach((key) => {
|
|
34
|
+
if (resources.has(key)) { throw new Error('Manifest resource already claimed'); }
|
|
35
|
+
resources.add(key);
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { clickhouseSink, mqttSink } from '../../infrastructure/streams/index.js';
|
|
2
|
+
import activityTracking from '../../infrastructure/ingest/activity/activityTracking.js';
|
|
3
|
+
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
4
|
+
import amqpMqttRelay from '../../infrastructure/ingest/telemetry/amqpMqttRelay.js';
|
|
5
|
+
import modbusPublish from '../../infrastructure/ingest/mqtt/modbusPublish.js';
|
|
6
|
+
import mqttMetrics from '../../infrastructure/ingest/mqtt/mqttMetrics.js';
|
|
7
|
+
import { parseModbusDeviceSpec } from '../../infrastructure/ingest/mqtt/modbusDeviceSpec.js';
|
|
8
|
+
import compositeLifecycle from './compositeLifecycle.js';
|
|
9
|
+
import resolveSecrets from './resolveSecrets.js';
|
|
10
|
+
|
|
11
|
+
function configured(spec, context) {
|
|
12
|
+
const options = { maxRecords: spec.limits?.maxRecords, maxBytes: spec.limits?.maxBytes, budget: context.budget };
|
|
13
|
+
const defined = Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
|
|
14
|
+
return { ...resolveSecrets(spec, context.secrets),
|
|
15
|
+
config: { ...resolveSecrets(spec.config, context.secrets), ...defined },
|
|
16
|
+
options: { ...resolveSecrets(spec.options, context.secrets), ...defined } };
|
|
17
|
+
}
|
|
18
|
+
function adapter(factory, required, resourceKeys = () => { return []; }) {
|
|
19
|
+
return {
|
|
20
|
+
validate(spec) {
|
|
21
|
+
required.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing pipeline ${key}`); } });
|
|
22
|
+
},
|
|
23
|
+
resources(spec) { return resourceKeys(spec); },
|
|
24
|
+
create(spec, context) {
|
|
25
|
+
let pipeline;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
return {
|
|
28
|
+
start() {
|
|
29
|
+
context.signal.throwIfAborted();
|
|
30
|
+
if (stopped) { throw new Error('Pipeline stopped'); }
|
|
31
|
+
pipeline ??= factory(configured(spec, context));
|
|
32
|
+
return pipeline.start();
|
|
33
|
+
},
|
|
34
|
+
stop() { stopped = true; return pipeline?.stop(); }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function modbusResources(spec) {
|
|
40
|
+
const devices = spec.config.devices?.split(',').map(parseModbusDeviceSpec) ?? [];
|
|
41
|
+
const paths = devices.filter((device) => { return device.kind === 'rtu'; }).map((device) => { return `serial:${device.path}`; });
|
|
42
|
+
const addresses = spec.config.addresses ?? [];
|
|
43
|
+
addresses.filter((address) => { return address.startsWith('rtu://'); }).forEach((address) => {
|
|
44
|
+
paths.push(`serial:${address.slice(6).split(':')[0]}`);
|
|
45
|
+
});
|
|
46
|
+
return [...new Set(paths)];
|
|
47
|
+
}
|
|
48
|
+
function metrics(spec, factory) {
|
|
49
|
+
const sink = clickhouseSink(spec.clickhouseUrl, spec.table);
|
|
50
|
+
return compositeLifecycle({ outputs: [sink], inputs: [factory(sink)] });
|
|
51
|
+
}
|
|
52
|
+
export default function pipelineAdapters() {
|
|
53
|
+
return new Map([
|
|
54
|
+
['modbus', adapter((spec) => { return modbusPublish(spec.url, spec.config); }, ['url', 'config'], modbusResources)],
|
|
55
|
+
['activity', adapter((spec) => { return activityTracking(spec.lokiUrl, spec.clickhouseUrl, spec.query, spec.config); }, ['lokiUrl', 'clickhouseUrl', 'query', 'config'])],
|
|
56
|
+
['amqp-relay', adapter((spec) => { return amqpMqttRelay(spec.url, spec.queue, mqttSink(spec.mqttUrl, spec.options), spec.options); }, ['url', 'queue', 'mqttUrl'])],
|
|
57
|
+
['amqp-metrics', adapter((spec) => { return metrics(spec, (sink) => { return amqpMetricsIngest(spec.url, spec.queue, sink, spec.options); }); }, ['url', 'queue', 'clickhouseUrl', 'table'])],
|
|
58
|
+
['mqtt-metrics', adapter((spec) => { return metrics(spec, (sink) => { return mqttMetrics(spec.url, sink, spec.topic, spec.config); }); }, ['url', 'topic', 'clickhouseUrl', 'table', 'config'])]
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Resolve references only at resource construction; hashes and manifests retain refs. */
|
|
2
|
+
export default function resolveSecrets(value, secrets) {
|
|
3
|
+
if (Array.isArray(value)) { return value.map((item) => { return resolveSecrets(item, secrets); }); }
|
|
4
|
+
if (!value || typeof value !== 'object') { return value; }
|
|
5
|
+
if (Object.hasOwn(value, '$secret')) {
|
|
6
|
+
if (Object.keys(value).length !== 1 || typeof value.$secret !== 'string') { throw new TypeError('Invalid secret reference'); }
|
|
7
|
+
const resolved = secrets?.get(value.$secret);
|
|
8
|
+
if (resolved === undefined || resolved === null) { throw new Error('Required collector secret is unavailable'); }
|
|
9
|
+
return resolved;
|
|
10
|
+
}
|
|
11
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => { return [key, resolveSecrets(item, secrets)]; }));
|
|
12
|
+
}
|