@yarkivaev/scada 2.3.114 → 2.3.116
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 +109 -0
- package/charts/scada/Chart.yaml +2 -2
- package/charts/scada/templates/_helpers.tpl +12 -4
- package/charts/scada/templates/clickhouse.yaml +3 -3
- package/charts/scada/templates/collector.yaml +2 -2
- package/charts/scada/templates/namespace.yaml +6 -0
- package/charts/scada/templates/postgres.yaml +3 -3
- package/charts/scada/templates/processes.yaml +88 -0
- package/charts/scada/templates/rabbitmq-config.yaml +91 -0
- package/charts/scada/templates/rabbitmq.yaml +54 -113
- package/charts/scada/templates/site-server.yaml +5 -3
- package/charts/scada/templates/supervisor-sink.yaml +4 -2
- package/charts/scada/values.schema.json +22 -0
- package/charts/scada/values.yaml +21 -0
- package/index.js +3 -0
- package/package.json +8 -3
- package/src/application/deploy/collectorProcesses.js +43 -0
- package/src/application/deploy/expandFlows.js +21 -10
- package/src/application/deploy/publishedStreams.js +30 -0
- package/src/application/deploy/releaseEdges.js +13 -16
- package/src/application/deploy/releaseNodes.js +13 -0
- package/src/application/deploy/releaseValues.js +4 -15
- package/src/application/deploy/releases.js +3 -1
- package/src/application/deploy/subscriptions.js +20 -0
- package/src/infrastructure/deploy/executeRelease.js +102 -0
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/modbus/silentStreams.js +9 -11
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +7 -18
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +8 -17
- package/src/infrastructure/messaging/rabbitmq/definitions.js +24 -0
- package/src/infrastructure/messaging/rabbitmq/reconcile.js +104 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +80 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +60 -0
- package/src/infrastructure/opcua/source.js +35 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
|
@@ -1,169 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function connectBroker(url) {
|
|
4
|
-
return amqp.connect(url, { heartbeat: 10 });
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function scheduleLater(fn, pause) {
|
|
8
|
-
return setTimeout(fn, pause);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function clearLater(handle) {
|
|
12
|
-
clearTimeout(handle);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function reportFailure(error) {
|
|
16
|
-
process.stderr.write(`amqp consume failed ${error.message}\n`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function nextDelay(delay) {
|
|
20
|
-
return Math.min(delay * 2, 30000);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function blankState() {
|
|
24
|
-
return {
|
|
25
|
-
generation: 0,
|
|
26
|
-
timer: undefined,
|
|
27
|
-
stopping: false,
|
|
28
|
-
delay: 1000,
|
|
29
|
-
session: undefined
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function running(state) {
|
|
34
|
-
return Boolean(state.session) || (state.generation > 0 && !state.stopping);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function scheduleRetry(ctx, generation) {
|
|
38
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const pause = ctx.state.delay;
|
|
42
|
-
ctx.state.delay = nextDelay(ctx.state.delay);
|
|
43
|
-
ctx.state.timer = ctx.schedule(() => {
|
|
44
|
-
ctx.state.timer = undefined;
|
|
45
|
-
return ctx.open(generation);
|
|
46
|
-
}, pause);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function bindClose(ctx, generation, live) {
|
|
50
|
-
let dropped = false;
|
|
51
|
-
function lose() {
|
|
52
|
-
if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
dropped = true;
|
|
56
|
-
if (ctx.state.session === live) {
|
|
57
|
-
ctx.state.session = undefined;
|
|
58
|
-
}
|
|
59
|
-
scheduleRetry(ctx, generation);
|
|
60
|
-
}
|
|
61
|
-
live.connection.on('error', lose);
|
|
62
|
-
live.connection.on('close', lose);
|
|
63
|
-
live.channel.on('error', lose);
|
|
64
|
-
live.channel.on('close', lose);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function accept(ctx, generation, connection) {
|
|
68
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
69
|
-
await connection.close();
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
return connection.createChannel();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function keep(ctx, generation, connection, channel, tag) {
|
|
76
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
77
|
-
await channel.close();
|
|
78
|
-
await connection.close();
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
const live = { connection, channel, tag };
|
|
82
|
-
ctx.state.session = live;
|
|
83
|
-
ctx.state.delay = 1000;
|
|
84
|
-
bindClose(ctx, generation, live);
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function open(ctx, generation) {
|
|
89
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const connection = await ctx.connect(ctx.url);
|
|
94
|
-
const channel = await accept(ctx, generation, connection);
|
|
95
|
-
if (!channel) {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const tag = await ctx.subscribe(channel);
|
|
99
|
-
await keep(ctx, generation, connection, channel, tag);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
ctx.report(error);
|
|
102
|
-
scheduleRetry(ctx, generation);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function begin(ctx) {
|
|
107
|
-
if (running(ctx.state)) {
|
|
108
|
-
return Promise.resolve();
|
|
109
|
-
}
|
|
110
|
-
ctx.state.stopping = false;
|
|
111
|
-
ctx.state.generation += 1;
|
|
112
|
-
ctx.state.delay = 1000;
|
|
113
|
-
return open(ctx, ctx.state.generation);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function end(ctx) {
|
|
117
|
-
ctx.state.stopping = true;
|
|
118
|
-
ctx.state.generation += 1;
|
|
119
|
-
if (ctx.state.timer) {
|
|
120
|
-
ctx.clear(ctx.state.timer);
|
|
121
|
-
ctx.state.timer = undefined;
|
|
122
|
-
}
|
|
123
|
-
const live = ctx.state.session;
|
|
124
|
-
ctx.state.session = undefined;
|
|
125
|
-
if (!live) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
await live.channel.cancel(live.tag);
|
|
129
|
-
await live.channel.close();
|
|
130
|
-
await live.connection.close();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Keeps one AMQP consumer subscribed across broker restarts.
|
|
135
|
-
*
|
|
136
|
-
* @param {string} url - RabbitMQ AMQP URL
|
|
137
|
-
* @param {function} subscribe - async (channel) => consumerTag
|
|
138
|
-
* @param {object} [options] - connect, schedule, clear, report overrides
|
|
139
|
-
* @returns {object} session with start and stop
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* const session = durableConsume('amqp://localhost', async (channel) => {
|
|
143
|
-
* const tag = await channel.consume('scada.telemetry.ingest', () => {});
|
|
144
|
-
* return tag.consumerTag;
|
|
145
|
-
* });
|
|
146
|
-
* await session.start();
|
|
147
|
-
*/
|
|
148
|
-
export default function durableConsume(url, subscribe, options = {}) {
|
|
149
|
-
const ctx = {
|
|
150
|
-
url,
|
|
151
|
-
subscribe,
|
|
152
|
-
connect: options.connect || connectBroker,
|
|
153
|
-
schedule: options.schedule || scheduleLater,
|
|
154
|
-
clear: options.clear || clearLater,
|
|
155
|
-
report: options.report || reportFailure,
|
|
156
|
-
state: blankState(),
|
|
157
|
-
open(generation) {
|
|
158
|
-
return open(ctx, generation);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
return {
|
|
162
|
-
start() {
|
|
163
|
-
return begin(ctx);
|
|
164
|
-
},
|
|
165
|
-
stop() {
|
|
166
|
-
return end(ctx);
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
}
|
|
1
|
+
// Compatibility facade; transport lifecycle belongs to source-to-sink.
|
|
2
|
+
export { amqpSession as default } from '@yarkivaev/source-to-sink';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clock } from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { clock, intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Whether a stream is past the silence budget.
|
|
@@ -83,15 +83,12 @@ function gateState(config) {
|
|
|
83
83
|
intervalSec: interval,
|
|
84
84
|
sources,
|
|
85
85
|
clk: config.clock || clock(),
|
|
86
|
-
delay: config.delay ||
|
|
87
|
-
|
|
88
|
-
}),
|
|
89
|
-
clear: config.clear || clearInterval,
|
|
86
|
+
delay: config.delay || setTimeout,
|
|
87
|
+
clear: config.clear || clearTimeout,
|
|
90
88
|
lastSeen: new Map(),
|
|
91
89
|
held: new Map(),
|
|
92
90
|
live: new Set(),
|
|
93
91
|
collector: undefined,
|
|
94
|
-
timer: undefined,
|
|
95
92
|
running: false
|
|
96
93
|
};
|
|
97
94
|
}
|
|
@@ -119,6 +116,9 @@ export default function silentStreams(config) {
|
|
|
119
116
|
function runPulse() {
|
|
120
117
|
pulse(state);
|
|
121
118
|
}
|
|
119
|
+
const ticker = intervalSource(state.intervalSec * 1000, { accept: runPulse }, {
|
|
120
|
+
schedule: state.delay, clear: state.clear
|
|
121
|
+
});
|
|
122
122
|
return {
|
|
123
123
|
seen(name, at = state.clk.millis()) {
|
|
124
124
|
state.lastSeen.set(name, at);
|
|
@@ -134,16 +134,14 @@ export default function silentStreams(config) {
|
|
|
134
134
|
};
|
|
135
135
|
},
|
|
136
136
|
start() {
|
|
137
|
+
if (state.running) { return; }
|
|
137
138
|
state.running = true;
|
|
138
139
|
runPulse();
|
|
139
|
-
|
|
140
|
+
void ticker.start();
|
|
140
141
|
},
|
|
141
142
|
stop() {
|
|
142
143
|
state.running = false;
|
|
143
|
-
|
|
144
|
-
state.clear(state.timer);
|
|
145
|
-
state.timer = undefined;
|
|
146
|
-
}
|
|
144
|
+
void ticker.stop();
|
|
147
145
|
for (const name of [...state.live]) {
|
|
148
146
|
stopOne(state, name);
|
|
149
147
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Periodic wall-clock sweep that closes stale open segments without waiting for STOMP.
|
|
3
5
|
*
|
|
@@ -18,46 +20,7 @@ export default function silentOpenWatch(closer, budget, options) {
|
|
|
18
20
|
throw new Error(`Budget must be a positive number: ${budget}`);
|
|
19
21
|
}
|
|
20
22
|
const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function claim() {
|
|
25
|
-
const free = !gate.busy;
|
|
26
|
-
gate.busy = true;
|
|
27
|
-
return free;
|
|
28
|
-
}
|
|
29
|
-
function release() {
|
|
30
|
-
gate.busy = false;
|
|
31
|
-
}
|
|
32
|
-
async function tick() {
|
|
33
|
-
if (!claim()) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
await closer.close(budget);
|
|
38
|
-
} catch {
|
|
39
|
-
/* next tick retries */
|
|
40
|
-
}
|
|
41
|
-
release();
|
|
42
|
-
}
|
|
43
|
-
return {
|
|
44
|
-
async start() {
|
|
45
|
-
if (active) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
active = true;
|
|
49
|
-
await tick();
|
|
50
|
-
timer = setInterval(() => {
|
|
51
|
-
void tick();
|
|
52
|
-
}, intervalMs);
|
|
53
|
-
},
|
|
54
|
-
stop() {
|
|
55
|
-
if (!active) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
active = false;
|
|
59
|
-
clearInterval(timer);
|
|
60
|
-
timer = undefined;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
23
|
+
return intervalSource(intervalMs, {
|
|
24
|
+
accept() { return closer.close(budget); }
|
|
25
|
+
}, { immediate: true });
|
|
63
26
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
2
|
-
import durableConsume from '../durableConsume.js';
|
|
1
|
+
import { amqpSource, batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
3
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
4
3
|
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
5
4
|
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
@@ -20,7 +19,7 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
|
20
19
|
onSeen(name);
|
|
21
20
|
}
|
|
22
21
|
}
|
|
23
|
-
codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
22
|
+
return codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -50,16 +49,6 @@ export function telemetryConsumer(codec, channel, onSeen) {
|
|
|
50
49
|
* @param {object} [options] - batch and prefetch options
|
|
51
50
|
* @returns {object} ingest with start and stop
|
|
52
51
|
*/
|
|
53
|
-
function subscribeMetrics(queue, prefetch, codec, onSeen) {
|
|
54
|
-
return async (channel) => {
|
|
55
|
-
await channel.assertQueue(queue, { durable: true });
|
|
56
|
-
channel.prefetch(prefetch);
|
|
57
|
-
const onMessage = telemetryConsumer(codec, channel, onSeen);
|
|
58
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
59
|
-
return tag.consumerTag;
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
64
53
|
const prefetch = options.prefetch || 32;
|
|
65
54
|
const size = options.size || 100;
|
|
@@ -70,11 +59,11 @@ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
|
70
59
|
const breaker = circuit(threshold, timeout, clk);
|
|
71
60
|
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
72
61
|
const codec = metricsCodec(collector);
|
|
73
|
-
const session =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
62
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
63
|
+
accept(delivery) {
|
|
64
|
+
return acceptTelemetryDeliver(codec, delivery.fields, delivery.content, options.onSeen);
|
|
65
|
+
}
|
|
66
|
+
}, { ...options, prefetch });
|
|
78
67
|
return {
|
|
79
68
|
start() {
|
|
80
69
|
return session.start();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { amqpSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,24 +34,14 @@ export function relayConsumer(sink, channel) {
|
|
|
34
34
|
* @param {object} [options] - prefetch count
|
|
35
35
|
* @returns {object} Relay with start and stop
|
|
36
36
|
*/
|
|
37
|
-
function subscribeRelay(queue, prefetch, sink) {
|
|
38
|
-
return async (channel) => {
|
|
39
|
-
await channel.assertQueue(queue, { durable: true });
|
|
40
|
-
channel.prefetch(prefetch);
|
|
41
|
-
const onMessage = relayConsumer(sink, channel);
|
|
42
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
43
|
-
return tag.consumerTag;
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
37
|
export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
48
38
|
const prefetch = options.prefetch || 32;
|
|
49
39
|
let opened = false;
|
|
50
|
-
const session =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
40
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
41
|
+
accept(delivery) {
|
|
42
|
+
return sink.write([deliverToMqttRecord(delivery.fields, delivery.content)]);
|
|
43
|
+
}
|
|
44
|
+
}, { ...options, prefetch });
|
|
55
45
|
return {
|
|
56
46
|
start() {
|
|
57
47
|
if (!opened) {
|
|
@@ -62,7 +52,8 @@ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
|
62
52
|
},
|
|
63
53
|
async stop() {
|
|
64
54
|
await session.stop();
|
|
65
|
-
sink.stop();
|
|
55
|
+
await sink.stop();
|
|
56
|
+
opened = false;
|
|
66
57
|
}
|
|
67
58
|
};
|
|
68
59
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Builds durable topic exchanges, queues and AMQP 0-9-1 outboxes for one virtual host. */
|
|
2
|
+
export default function rabbitmqDefinitions({ vhost, users = [], permissions = [], exchanges = [],
|
|
3
|
+
queues = [], bindings = [], outboxes = [] }) {
|
|
4
|
+
if (!vhost) { throw new Error('a virtual host is required'); }
|
|
5
|
+
const routes = outboxes.flatMap(({ exchange, queue, keys }) => {
|
|
6
|
+
return keys.map((key) => { return { source: exchange, destination: queue, key }; });
|
|
7
|
+
});
|
|
8
|
+
const names = [...queues, ...outboxes.map(({ queue }) => { return queue; })];
|
|
9
|
+
return { vhosts: [{ name: vhost }], users, permissions,
|
|
10
|
+
exchanges: exchanges.map((name) => { return { name, vhost, type: 'topic', durable: true, auto_delete: false }; }),
|
|
11
|
+
queues: [...new Set(names)].map((name) => { return { name, vhost, durable: true, auto_delete: false, arguments: {} }; }),
|
|
12
|
+
bindings: [...bindings, ...routes].map(({ source, destination, key }) => {
|
|
13
|
+
return { source, vhost, destination, destination_type: 'queue', routing_key: key, arguments: {} };
|
|
14
|
+
}),
|
|
15
|
+
parameters: outboxes.filter(({ shovel }) => { return shovel; }).map(({ queue, shovel }) => {
|
|
16
|
+
return { vhost, component: 'shovel', name: shovel.name, value: {
|
|
17
|
+
'src-protocol': 'amqp091', 'src-uri': shovel.sourceUri, 'src-queue': queue,
|
|
18
|
+
'dest-protocol': 'amqp091', 'dest-uri': shovel.destinationUri,
|
|
19
|
+
'dest-exchange': shovel.destinationExchange, 'reconnect-delay': shovel.reconnectDelay,
|
|
20
|
+
'dest-publish-properties': { delivery_mode: 2 }
|
|
21
|
+
} };
|
|
22
|
+
})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** This module is self-contained so clients can ship it to an existing Node.js pod. */
|
|
2
|
+
function canonical(value) {
|
|
3
|
+
if (Array.isArray(value)) { return value.map(canonical); }
|
|
4
|
+
if (value && typeof value === 'object') {
|
|
5
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => {
|
|
6
|
+
return [key, canonical(value[key])];
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Runtime counters and other fields outside the declaration do not require a write. */
|
|
13
|
+
function same(actual, desired) {
|
|
14
|
+
return actual && Object.keys(desired).every((key) => {
|
|
15
|
+
return JSON.stringify(canonical(actual[key])) === JSON.stringify(canonical(desired[key]));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function apiAt({ base, username, password }) {
|
|
20
|
+
if (!base || typeof username !== 'string' || typeof password !== 'string') {
|
|
21
|
+
throw new Error('management base, username and password are required');
|
|
22
|
+
}
|
|
23
|
+
const auth = Buffer.from(`${username}:${password}`).toString('base64');
|
|
24
|
+
return async (method, path, body) => {
|
|
25
|
+
const response = await fetch(`${base.replace(/\/$/u, '')}/api/${path}`, { method,
|
|
26
|
+
headers: { authorization: `Basic ${auth}`, 'content-type': 'application/json' },
|
|
27
|
+
body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
|
|
28
|
+
if (!response.ok) { throw new Error(`broker ${method} ${path}: HTTP ${response.status}`); }
|
|
29
|
+
const text = await response.text();
|
|
30
|
+
return text ? JSON.parse(text) : null;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A conflicting immutable declaration fails in place, preserving the stored queue. */
|
|
35
|
+
async function declare(api, path, desired) {
|
|
36
|
+
const current = await api('GET', path);
|
|
37
|
+
const changes = desired.filter((item) => {
|
|
38
|
+
return !same(current.find((row) => { return row.name === item.name; }), item);
|
|
39
|
+
});
|
|
40
|
+
await Promise.all(changes.map((item) => { return api('PUT', `${path}/${encodeURIComponent(item.name)}`, item); }));
|
|
41
|
+
return changes.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function identity(item) {
|
|
45
|
+
return { source: item.source, destination: item.destination, kind: item.destination_type || 'queue',
|
|
46
|
+
key: item.routing_key, arguments: item.arguments || {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bindingPath(path, item) {
|
|
50
|
+
const kind = item.destination_type === 'exchange' ? 'e' : 'q';
|
|
51
|
+
return `${path}/e/${encodeURIComponent(item.source)}/${kind}/${encodeURIComponent(item.destination)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function bindings(api, path, desired, ownsBinding) {
|
|
55
|
+
const current = await api('GET', path);
|
|
56
|
+
function includes(rows, item) {
|
|
57
|
+
return rows.some((row) => { return same(identity(row), identity(item)); });
|
|
58
|
+
}
|
|
59
|
+
const missing = desired.filter((item) => { return !includes(current, item); });
|
|
60
|
+
const stale = current.filter((item) => { return item.source && ownsBinding?.(item) && !includes(desired, item); });
|
|
61
|
+
await Promise.all(missing.map((item) => {
|
|
62
|
+
return api('POST', bindingPath(path, item), { routing_key: item.routing_key, arguments: item.arguments || {} });
|
|
63
|
+
}));
|
|
64
|
+
await Promise.all(stale.map((item) => {
|
|
65
|
+
return api('DELETE', `${bindingPath(path, item)}/${encodeURIComponent(item.properties_key)}`);
|
|
66
|
+
}));
|
|
67
|
+
return missing.length + stale.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function shovels(api, path, desired, ownsShovel) {
|
|
71
|
+
const current = await api('GET', path);
|
|
72
|
+
const changed = desired.filter((item) => {
|
|
73
|
+
const found = current.find((row) => { return row.name === item.name; });
|
|
74
|
+
return !same(found, { value: item.value });
|
|
75
|
+
});
|
|
76
|
+
const stale = current.filter((item) => {
|
|
77
|
+
return ownsShovel?.(item) && !desired.some((row) => { return row.name === item.name; });
|
|
78
|
+
});
|
|
79
|
+
await Promise.all(changed.map((item) => {
|
|
80
|
+
return api('PUT', `${path}/${encodeURIComponent(item.name)}`, { value: item.value });
|
|
81
|
+
}));
|
|
82
|
+
await Promise.all(stale.map((item) => { return api('DELETE', `${path}/${encodeURIComponent(item.name)}`); }));
|
|
83
|
+
return changed.length + stale.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Apply only topology belonging to the selected virtual host. Users and permissions are bootstrap data. */
|
|
87
|
+
export default async function reconcileRabbitmq(desired, options) {
|
|
88
|
+
const { vhost, ownsBinding, ownsShovel } = options;
|
|
89
|
+
if (!vhost) { throw new Error('a virtual host is required'); }
|
|
90
|
+
const api = apiAt(options);
|
|
91
|
+
const host = encodeURIComponent(vhost);
|
|
92
|
+
const objects = [...desired.exchanges, ...desired.queues, ...desired.bindings, ...desired.parameters];
|
|
93
|
+
if (objects.some((item) => { return item.vhost !== undefined && item.vhost !== vhost; })) {
|
|
94
|
+
throw new Error('topology contains a different virtual host');
|
|
95
|
+
}
|
|
96
|
+
if (desired.parameters.some((item) => { return item.component !== 'shovel'; })) {
|
|
97
|
+
throw new Error('only shovel parameters can be reconciled');
|
|
98
|
+
}
|
|
99
|
+
const exchanges = await declare(api, `exchanges/${host}`, desired.exchanges);
|
|
100
|
+
const queues = await declare(api, `queues/${host}`, desired.queues);
|
|
101
|
+
const links = await bindings(api, `bindings/${host}`, desired.bindings, ownsBinding);
|
|
102
|
+
const parameters = await shovels(api, `parameters/shovel/${host}`, desired.parameters, ownsShovel);
|
|
103
|
+
return exchanges + queues + links + parameters;
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function positive(value, name) {
|
|
2
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
3
|
+
throw new TypeError(`${name} must be a positive number`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
function validateChannels(channels) {
|
|
7
|
+
if (!Array.isArray(channels) || channels.length === 0) {
|
|
8
|
+
throw new TypeError('channels must be a nonempty array');
|
|
9
|
+
}
|
|
10
|
+
const nodes = new Set();
|
|
11
|
+
channels.forEach(({ nodeId }) => {
|
|
12
|
+
if (typeof nodeId !== 'string' || !nodeId.trim() || nodes.has(nodeId)) {
|
|
13
|
+
throw new TypeError('invalid or duplicate nodeId');
|
|
14
|
+
}
|
|
15
|
+
nodes.add(nodeId);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validUrl(value) {
|
|
20
|
+
const url = new URL(value);
|
|
21
|
+
return url.protocol === 'opc.tcp:' && Boolean(url.hostname) && !url.username && !url.password;
|
|
22
|
+
}
|
|
23
|
+
export function validateConnections(connections) {
|
|
24
|
+
if (!Array.isArray(connections)) {
|
|
25
|
+
throw new TypeError('connections must be an array');
|
|
26
|
+
}
|
|
27
|
+
const urls = new Set();
|
|
28
|
+
connections.forEach((connection) => {
|
|
29
|
+
if (typeof connection.url !== 'string' || !validUrl(connection.url) || urls.has(connection.url)) {
|
|
30
|
+
throw new TypeError('invalid or duplicate OPC UA URL');
|
|
31
|
+
}
|
|
32
|
+
urls.add(connection.url);
|
|
33
|
+
positive(connection.publishMs, 'publishMs');
|
|
34
|
+
positive(connection.heartbeatMs, 'heartbeatMs');
|
|
35
|
+
validateChannels(connection.channels);
|
|
36
|
+
});
|
|
37
|
+
return connections;
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { OPCUAClient } from 'node-opcua';
|
|
2
|
+
import session from './session.js';
|
|
3
|
+
|
|
4
|
+
function report(options, error, connection) {
|
|
5
|
+
try {
|
|
6
|
+
options.onError?.(error, connection);
|
|
7
|
+
} catch {
|
|
8
|
+
// Observability callbacks must not break endpoint recovery.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function delay(state, milliseconds) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
state.wake = resolve;
|
|
14
|
+
state.timer = setTimeout(resolve, milliseconds);
|
|
15
|
+
}).finally(() => { clearTimeout(state.timer); });
|
|
16
|
+
}
|
|
17
|
+
function clientFor(options) {
|
|
18
|
+
return OPCUAClient.create({ endpointMustExist: false, keepSessionAlive: false,
|
|
19
|
+
connectionStrategy: { maxRetry: 0 }, transportTimeout: options.connectTimeoutMs,
|
|
20
|
+
defaultTransactionTimeout: options.requestTimeoutMs, requestedSessionTimeout: options.requestTimeoutMs * 2 });
|
|
21
|
+
}
|
|
22
|
+
function disconnection(client) {
|
|
23
|
+
let pending;
|
|
24
|
+
return () => {
|
|
25
|
+
pending ??= client.disconnect();
|
|
26
|
+
return pending;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function attempt(state, connection, publish, options) {
|
|
30
|
+
const client = clientFor(options);
|
|
31
|
+
const close = disconnection(client);
|
|
32
|
+
state.close = close;
|
|
33
|
+
const current = { active: true, connection, wake: null, timer: null, pending: new Set() };
|
|
34
|
+
state.current = current;
|
|
35
|
+
try {
|
|
36
|
+
await client.connect(connection.url);
|
|
37
|
+
if (state.active) {
|
|
38
|
+
await session(current, client, connection, publish, options);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (state.active) {
|
|
42
|
+
report(options, error, connection);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
current.active = false;
|
|
46
|
+
try { await close(); } finally { await Promise.all(current.pending); }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function run(state, connection, publish, options) {
|
|
50
|
+
while (state.active) {
|
|
51
|
+
// Each endpoint deliberately serializes attempts and heartbeat reads.
|
|
52
|
+
// eslint-disable-next-line no-await-in-loop
|
|
53
|
+
await attempt(state, connection, publish, options);
|
|
54
|
+
if (state.active) {
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop
|
|
56
|
+
await delay(state, options.retryMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export default function endpoint(connection, publish, options) {
|
|
61
|
+
const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
|
|
62
|
+
return {
|
|
63
|
+
start() {
|
|
64
|
+
if (!state.active) {
|
|
65
|
+
state.active = true;
|
|
66
|
+
state.task = run(state, connection, publish, options);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async stop() {
|
|
70
|
+
state.active = false;
|
|
71
|
+
if (state.current) {
|
|
72
|
+
state.current.active = false;
|
|
73
|
+
state.current.wake?.();
|
|
74
|
+
}
|
|
75
|
+
state.wake?.();
|
|
76
|
+
await state.close?.();
|
|
77
|
+
await state.task;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import opcuaSource from './source.js';
|
|
2
|
+
export { validateConnections } from './config.js';
|
|
3
|
+
|
|
4
|
+
/** Compatibility adapter for callback-based SCADA consumers. */
|
|
5
|
+
export default function opcuaConnections(connections, publish, options = {}) {
|
|
6
|
+
if (typeof publish !== 'function') { throw new TypeError('publish must be a function'); }
|
|
7
|
+
return opcuaSource(connections, { accept: ({ channel, value }) => { return publish(channel, value); } }, options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { default as opcuaSource } from './source.js';
|