@yarkivaev/scada 2.3.90 → 2.3.112
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 +6 -1
- package/charts/scada/Chart.yaml +6 -0
- package/charts/scada/templates/_helpers.tpl +63 -0
- package/charts/scada/templates/clickhouse.yaml +57 -0
- package/charts/scada/templates/collector.yaml +115 -0
- package/charts/scada/templates/postgres.yaml +70 -0
- package/charts/scada/templates/rabbitmq.yaml +150 -0
- package/charts/scada/templates/site-server.yaml +110 -0
- package/charts/scada/templates/supervisor-sink.yaml +64 -0
- package/charts/scada/values.yaml +30 -0
- package/index.js +10 -0
- package/package.json +3 -1
- package/src/application/deploy/appReleases.js +50 -0
- package/src/application/deploy/applyIncludes.js +31 -0
- package/src/application/deploy/expand.js +49 -0
- package/src/application/deploy/expandDevices.js +135 -0
- package/src/application/deploy/expandFlows.js +281 -0
- package/src/application/deploy/expandNamespace.js +193 -0
- package/src/application/deploy/expandPattern.js +207 -0
- package/src/application/deploy/expandProfiles.js +70 -0
- package/src/application/deploy/frozen.js +23 -0
- package/src/application/deploy/mergeDocuments.js +98 -0
- package/src/application/deploy/paths.js +86 -0
- package/src/application/deploy/pointPaths.js +20 -0
- package/src/application/deploy/readDocument.js +169 -0
- package/src/application/deploy/releaseEdges.js +185 -0
- package/src/application/deploy/releaseNodes.js +118 -0
- package/src/application/deploy/releaseValues.js +200 -0
- package/src/application/deploy/releases.js +115 -0
- package/src/application/deploy/withCollectors.js +28 -0
- package/src/bin/modbus-to-mqtt.js +57 -0
- package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
- package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
- package/src/infrastructure/ingest/mqtt/registerPublish.js +84 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import applyIncludes from './applyIncludes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses one scalar token.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} text - raw scalar
|
|
7
|
+
* @returns {string|number|boolean|null} scalar
|
|
8
|
+
*/
|
|
9
|
+
function scalar(text) {
|
|
10
|
+
const trimmed = text.trim();
|
|
11
|
+
if (trimmed === 'true') {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (trimmed === 'false') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (trimmed === 'null') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (/^-?\d+$/u.test(trimmed)) {
|
|
21
|
+
return Number(trimmed);
|
|
22
|
+
}
|
|
23
|
+
if (trimmed.length > 1 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
24
|
+
return trimmed.slice(1, -1);
|
|
25
|
+
}
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Splits `key: value` on the first colon.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} body - line without indent
|
|
33
|
+
* @returns {{key: string, rest: string}} key and remainder
|
|
34
|
+
*/
|
|
35
|
+
function splitKey(body) {
|
|
36
|
+
const mark = body.indexOf(':');
|
|
37
|
+
if (mark < 0) {
|
|
38
|
+
return { key: '', rest: body.trim() };
|
|
39
|
+
}
|
|
40
|
+
return { key: body.slice(0, mark).trim(), rest: body.slice(mark + 1).trim() };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parses the block that starts at index.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array<{indent: number, body: string}>} rows - content lines
|
|
47
|
+
* @param {number} index - first line
|
|
48
|
+
* @returns {{value: object|Array, next: number}} block and following index
|
|
49
|
+
*/
|
|
50
|
+
function block(rows, index) {
|
|
51
|
+
if (index >= rows.length) {
|
|
52
|
+
return { value: {}, next: index };
|
|
53
|
+
}
|
|
54
|
+
if (rows[index].body.startsWith('- ')) {
|
|
55
|
+
// eslint-disable-next-line no-use-before-define -- list and map call back into block
|
|
56
|
+
return list(rows, index);
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line no-use-before-define -- list and map call back into block
|
|
59
|
+
return map(rows, index);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parses a map whose keys share the indent of the first row.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{indent: number, body: string}>} rows - content lines
|
|
66
|
+
* @param {number} index - first key
|
|
67
|
+
* @returns {{value: object, next: number}} map and following index
|
|
68
|
+
*/
|
|
69
|
+
function map(rows, index) {
|
|
70
|
+
const value = {};
|
|
71
|
+
const { indent } = rows[index];
|
|
72
|
+
let cursor = index;
|
|
73
|
+
while (cursor < rows.length && rows[cursor].indent === indent && !rows[cursor].body.startsWith('- ')) {
|
|
74
|
+
const piece = splitKey(rows[cursor].body);
|
|
75
|
+
if (piece.rest === '') {
|
|
76
|
+
const nested = block(rows, cursor + 1);
|
|
77
|
+
value[piece.key] = nested.value;
|
|
78
|
+
cursor = nested.next;
|
|
79
|
+
} else {
|
|
80
|
+
value[piece.key] = scalar(piece.rest);
|
|
81
|
+
cursor += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { value, next: cursor };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parses a list whose items share the indent of the first row.
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<{indent: number, body: string}>} rows - content lines
|
|
91
|
+
* @param {number} index - first item
|
|
92
|
+
* @returns {{value: Array, next: number}} list and following index
|
|
93
|
+
*/
|
|
94
|
+
function list(rows, index) {
|
|
95
|
+
const value = [];
|
|
96
|
+
const { indent } = rows[index];
|
|
97
|
+
let cursor = index;
|
|
98
|
+
while (cursor < rows.length && rows[cursor].indent === indent && rows[cursor].body.startsWith('- ')) {
|
|
99
|
+
// eslint-disable-next-line no-use-before-define -- item is the list element parser
|
|
100
|
+
const taken = item(rows, cursor, indent);
|
|
101
|
+
value.push(taken.value);
|
|
102
|
+
cursor = taken.next;
|
|
103
|
+
}
|
|
104
|
+
return { value, next: cursor };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Copies keys that sit under a list item after its first line.
|
|
109
|
+
*
|
|
110
|
+
* @param {Array<{indent: number, body: string}>} rows - content lines
|
|
111
|
+
* @param {number} cursor - first continuation line
|
|
112
|
+
* @param {number} indent - item indent
|
|
113
|
+
* @param {object} value - item being built
|
|
114
|
+
* @returns {number} index after those keys
|
|
115
|
+
*/
|
|
116
|
+
function siblings(rows, cursor, indent, value) {
|
|
117
|
+
if (cursor >= rows.length || rows[cursor].indent <= indent || rows[cursor].body.startsWith('- ')) {
|
|
118
|
+
return cursor;
|
|
119
|
+
}
|
|
120
|
+
const more = map(rows, cursor);
|
|
121
|
+
Object.assign(value, more.value);
|
|
122
|
+
return more.next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parses one list item, including keys on the following lines.
|
|
127
|
+
*
|
|
128
|
+
* @param {Array<{indent: number, body: string}>} rows - content lines
|
|
129
|
+
* @param {number} index - item line
|
|
130
|
+
* @param {number} indent - item indent
|
|
131
|
+
* @returns {{value: object|string|number|boolean|null, next: number}} item
|
|
132
|
+
*/
|
|
133
|
+
function item(rows, index, indent) {
|
|
134
|
+
const body = rows[index].body.slice(2);
|
|
135
|
+
const piece = splitKey(body);
|
|
136
|
+
if (piece.key === '') {
|
|
137
|
+
return { value: scalar(piece.rest), next: index + 1 };
|
|
138
|
+
}
|
|
139
|
+
const value = {};
|
|
140
|
+
if (piece.rest === '') {
|
|
141
|
+
const nested = block(rows, index + 1);
|
|
142
|
+
value[piece.key] = nested.value;
|
|
143
|
+
return { value, next: siblings(rows, nested.next, indent, value) };
|
|
144
|
+
}
|
|
145
|
+
value[piece.key] = scalar(piece.rest);
|
|
146
|
+
return { value, next: siblings(rows, index + 1, indent, value) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parses maps, lists, and scalars, then loads `include`.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* readDocument('servers:\n - id: cell\n');
|
|
154
|
+
*
|
|
155
|
+
* @param {string} text - document file contents
|
|
156
|
+
* @param {Function} [load] - reader for included file names
|
|
157
|
+
* @param {string[]} [stack] - includes already being read
|
|
158
|
+
* @returns {object} plain system document
|
|
159
|
+
*/
|
|
160
|
+
export default function readDocument(text, load, stack) {
|
|
161
|
+
const rows = [];
|
|
162
|
+
text.split('\n').forEach((raw) => {
|
|
163
|
+
if (raw.trim() !== '' && !raw.trimStart().startsWith('#')) {
|
|
164
|
+
const indent = raw.search(/\S/u);
|
|
165
|
+
rows.push({ indent, body: raw.slice(indent) });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return applyIncludes(block(rows, 0).value, load, stack || [], readDocument);
|
|
169
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isAddress } from './expandPattern.js';
|
|
2
|
+
import { asPath, objectPath, serverById } from './releaseNodes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compares two object lists without regard to order.
|
|
6
|
+
*
|
|
7
|
+
* @param {string[]} left - one side
|
|
8
|
+
* @param {string[]} right - the other side
|
|
9
|
+
* @returns {boolean} whether the lists name the same objects
|
|
10
|
+
* @example
|
|
11
|
+
* sameObjects(['mill/a'], ['mill/a']);
|
|
12
|
+
*/
|
|
13
|
+
function sameObjects(left, right) {
|
|
14
|
+
const first = left.slice().sort();
|
|
15
|
+
const second = right.slice().sort();
|
|
16
|
+
return first.length === second.length && first.every((item, index) => {
|
|
17
|
+
return item === second[index];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Records one directed edge, rejecting a disagreeing second side.
|
|
23
|
+
*
|
|
24
|
+
* @param {object[]} edges - accumulated edges
|
|
25
|
+
* @param {object} edge - one side
|
|
26
|
+
* @returns {void}
|
|
27
|
+
* @example
|
|
28
|
+
* remember([], { from: 'cell', object: 'unit', side: 'out', to: 'archive' });
|
|
29
|
+
*/
|
|
30
|
+
function remember(edges, edge) {
|
|
31
|
+
const found = edges.find((item) => {
|
|
32
|
+
return item.from === edge.from && item.to === edge.to;
|
|
33
|
+
});
|
|
34
|
+
if (!found) {
|
|
35
|
+
edges.push({
|
|
36
|
+
api: edge.api,
|
|
37
|
+
from: edge.from,
|
|
38
|
+
objects: [edge.object],
|
|
39
|
+
sides: { [edge.side]: [edge.object] },
|
|
40
|
+
to: edge.to
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const prior = found.sides[edge.side] ? found.sides[edge.side].slice() : [];
|
|
45
|
+
if (!prior.includes(edge.object)) {
|
|
46
|
+
prior.push(edge.object);
|
|
47
|
+
}
|
|
48
|
+
found.sides[edge.side] = prior;
|
|
49
|
+
found.objects = (found.sides.out || found.sides.in).slice();
|
|
50
|
+
if (edge.api) {
|
|
51
|
+
found.api = edge.api;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collects fan-out and server fan-in as one edge list.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} document - expanded document
|
|
59
|
+
* @returns {object[]} directed edges
|
|
60
|
+
* @example
|
|
61
|
+
* edgesOf({ servers: [] });
|
|
62
|
+
*/
|
|
63
|
+
export function edgesOf(document) {
|
|
64
|
+
const edges = [];
|
|
65
|
+
(document.servers || []).forEach((server) => {
|
|
66
|
+
(server.fan_out || []).forEach((flow) => {
|
|
67
|
+
const dest = serverById(document, flow.to);
|
|
68
|
+
remember(edges, {
|
|
69
|
+
from: server.id,
|
|
70
|
+
object: objectPath(server, flow.name),
|
|
71
|
+
side: 'out',
|
|
72
|
+
to: dest.id
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
(server.fan_in || []).forEach((flow) => {
|
|
76
|
+
if (isAddress(flow.from)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const source = serverById(document, flow.from);
|
|
80
|
+
remember(edges, {
|
|
81
|
+
api: flow.api,
|
|
82
|
+
from: source.id,
|
|
83
|
+
object: objectPath(source, flow.name),
|
|
84
|
+
side: 'in',
|
|
85
|
+
to: server.id
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
edges.forEach((edge) => {
|
|
90
|
+
const left = edge.sides.out;
|
|
91
|
+
const right = edge.sides.in;
|
|
92
|
+
if (left && right && !sameObjects(left, right)) {
|
|
93
|
+
throw new Error(`system document edge from ${edge.from} to ${edge.to} lists different objects`);
|
|
94
|
+
}
|
|
95
|
+
edge.objects = (left || right).slice();
|
|
96
|
+
});
|
|
97
|
+
return edges;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reports whether a held path covers an object path.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} held - path the server holds
|
|
104
|
+
* @param {string} objectId - object path
|
|
105
|
+
* @returns {boolean} true when one path covers the other
|
|
106
|
+
* @example
|
|
107
|
+
* covers('mill/unit/electrical', 'mill/unit');
|
|
108
|
+
*/
|
|
109
|
+
function covers(held, objectId) {
|
|
110
|
+
return held === objectId || held.startsWith(`${objectId}/`) || objectId.startsWith(`${held}/`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Lists telemetry and state paths a server holds directly.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} server - candidate holder
|
|
117
|
+
* @returns {string[]} held paths
|
|
118
|
+
* @example
|
|
119
|
+
* direct({ fan_in: [], id: 'cell' });
|
|
120
|
+
*/
|
|
121
|
+
function direct(server) {
|
|
122
|
+
const names = [];
|
|
123
|
+
(server.fan_in || []).forEach((flow) => {
|
|
124
|
+
if (isAddress(flow.from)) {
|
|
125
|
+
names.push(flow.name);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
(server.ownership || []).forEach((entry) => {
|
|
129
|
+
names.push(asPath(entry));
|
|
130
|
+
});
|
|
131
|
+
(server.objects || []).forEach((entry) => {
|
|
132
|
+
names.push(asPath(entry));
|
|
133
|
+
});
|
|
134
|
+
return names;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reports whether a server holds an object or receives it from someone who does.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} document - expanded document
|
|
141
|
+
* @param {object[]} edges - directed edges
|
|
142
|
+
* @param {string} serverId - candidate holder
|
|
143
|
+
* @param {string} objectId - object path
|
|
144
|
+
* @param {string[]} stack - servers already visited
|
|
145
|
+
* @returns {boolean} whether the server holds the object
|
|
146
|
+
* @example
|
|
147
|
+
* holds({ servers: [] }, [], 'cell', 'unit', []);
|
|
148
|
+
*/
|
|
149
|
+
function holds(document, edges, serverId, objectId, stack) {
|
|
150
|
+
const server = serverById(document, serverId);
|
|
151
|
+
if (direct(server).some((held) => {
|
|
152
|
+
return covers(held, objectId);
|
|
153
|
+
})) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (stack.includes(serverId)) {
|
|
157
|
+
throw new Error(`system document cycles ${objectId} at ${serverId}`);
|
|
158
|
+
}
|
|
159
|
+
const incoming = edges.filter((edge) => {
|
|
160
|
+
return edge.to === serverId && edge.objects.includes(objectId);
|
|
161
|
+
});
|
|
162
|
+
const next = stack.concat(serverId);
|
|
163
|
+
return incoming.some((edge) => {
|
|
164
|
+
return holds(document, edges, edge.from, objectId, next);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Rejects an edge whose source does not hold every listed object.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} document - expanded document
|
|
172
|
+
* @param {object[]} edges - directed edges
|
|
173
|
+
* @returns {void}
|
|
174
|
+
* @example
|
|
175
|
+
* assertHeld({ servers: [] }, []);
|
|
176
|
+
*/
|
|
177
|
+
export function assertHeld(document, edges) {
|
|
178
|
+
edges.forEach((edge) => {
|
|
179
|
+
edge.objects.forEach((objectId) => {
|
|
180
|
+
if (!holds(document, edges, edge.from, objectId, [])) {
|
|
181
|
+
throw new Error(`system document server ${edge.from} does not hold ${objectId}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an object entry as a path.
|
|
3
|
+
*
|
|
4
|
+
* @param {string|object} entry - object path or record
|
|
5
|
+
* @returns {string} path
|
|
6
|
+
* @example
|
|
7
|
+
* asPath('mill/unit');
|
|
8
|
+
*/
|
|
9
|
+
export function asPath(entry) {
|
|
10
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
11
|
+
return entry;
|
|
12
|
+
}
|
|
13
|
+
if (entry && typeof entry.id === 'string' && entry.id.length > 0) {
|
|
14
|
+
return entry.id;
|
|
15
|
+
}
|
|
16
|
+
throw new Error('system document object path is empty');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Finds the server object path that a flow name extends.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} server - source server
|
|
23
|
+
* @param {string} name - flow name
|
|
24
|
+
* @returns {string} object path, or the flow name when none is declared
|
|
25
|
+
* @example
|
|
26
|
+
* objectPath({ objects: ['mill/unit'] }, 'mill/unit/electrical');
|
|
27
|
+
*/
|
|
28
|
+
export function objectPath(server, name) {
|
|
29
|
+
const paths = (server.objects || []).map((entry) => {
|
|
30
|
+
return asPath(entry);
|
|
31
|
+
});
|
|
32
|
+
const found = paths.filter((path) => {
|
|
33
|
+
return name === path || name.startsWith(`${path}/`);
|
|
34
|
+
});
|
|
35
|
+
found.sort((left, right) => {
|
|
36
|
+
return right.length - left.length;
|
|
37
|
+
});
|
|
38
|
+
return found[0] || name;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Finds a server by id.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} document - expanded document
|
|
45
|
+
* @param {string} id - server id
|
|
46
|
+
* @returns {object} server
|
|
47
|
+
* @example
|
|
48
|
+
* serverById({ servers: [{ id: 'cell' }] }, 'cell');
|
|
49
|
+
*/
|
|
50
|
+
export function serverById(document, id) {
|
|
51
|
+
const found = (document.servers || []).find((server) => {
|
|
52
|
+
return server.id === id;
|
|
53
|
+
});
|
|
54
|
+
if (!found) {
|
|
55
|
+
throw new Error(`system document has no server ${id}`);
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads the cluster a server selects.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} document - expanded document
|
|
64
|
+
* @param {object} server - selected server
|
|
65
|
+
* @returns {string|null|undefined} cluster
|
|
66
|
+
* @example
|
|
67
|
+
* clusterOf({ profiles: { cell: {} } }, { profile: { id: 'cell', variables: { cluster: 'cell' } } });
|
|
68
|
+
*/
|
|
69
|
+
export function clusterOf(document, server) {
|
|
70
|
+
const label = server.profile && server.profile.id;
|
|
71
|
+
const profile = document.profiles && document.profiles[label];
|
|
72
|
+
if (!profile) {
|
|
73
|
+
throw new Error(`system document has no profile ${label}`);
|
|
74
|
+
}
|
|
75
|
+
const values = { ...(profile.variables || {}), ...(server.profile.variables || {}) };
|
|
76
|
+
return values.cluster;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Finds the one server installed into a cluster.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} document - expanded document
|
|
83
|
+
* @param {string} cluster - release selector
|
|
84
|
+
* @returns {object} server
|
|
85
|
+
* @example
|
|
86
|
+
* serverFor({ profiles: {}, servers: [] }, 'missing');
|
|
87
|
+
*/
|
|
88
|
+
export function serverFor(document, cluster) {
|
|
89
|
+
const found = (document.servers || []).filter((server) => {
|
|
90
|
+
return clusterOf(document, server) === cluster;
|
|
91
|
+
});
|
|
92
|
+
if (found.length === 0) {
|
|
93
|
+
throw new Error(`system document has no server for cluster ${cluster}`);
|
|
94
|
+
}
|
|
95
|
+
if (found.length > 1) {
|
|
96
|
+
throw new Error(`system document has several servers for cluster ${cluster}`);
|
|
97
|
+
}
|
|
98
|
+
return found[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Loads the profile a server names, with server variables overlaid.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} document - expanded document
|
|
105
|
+
* @param {object} server - selected server
|
|
106
|
+
* @returns {object} profile
|
|
107
|
+
* @example
|
|
108
|
+
* profileFor({ profiles: { cell: {} } }, { profile: { id: 'cell' } });
|
|
109
|
+
*/
|
|
110
|
+
export function profileFor(document, server) {
|
|
111
|
+
const label = server.profile && server.profile.id;
|
|
112
|
+
const profile = document.profiles && document.profiles[label];
|
|
113
|
+
if (!profile) {
|
|
114
|
+
throw new Error(`system document has no profile ${label}`);
|
|
115
|
+
}
|
|
116
|
+
const values = { ...(profile.variables || {}), ...(server.profile.variables || {}) };
|
|
117
|
+
return { ...profile, variables: values };
|
|
118
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { isAddress } from './expandPattern.js';
|
|
2
|
+
import { clusterOf, objectPath, serverById } from './releaseNodes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps one module name onto a chart product.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} name - module name
|
|
8
|
+
* @param {object} spec - module spec
|
|
9
|
+
* @param {string|undefined} secret - state-store Secret name
|
|
10
|
+
* @returns {[string, object]} chart key and values
|
|
11
|
+
* @example
|
|
12
|
+
* bindOne('telemetry_store', { enabled: true });
|
|
13
|
+
*/
|
|
14
|
+
function bindOne(name, spec, secret) {
|
|
15
|
+
if (name === 'state_store') {
|
|
16
|
+
return ['postgres', { enabled: spec.enabled !== false, secret: secret || 'scada-postgres' }];
|
|
17
|
+
}
|
|
18
|
+
if (name === 'message_bus') {
|
|
19
|
+
return ['rabbitmq', { enabled: spec.enabled !== false }];
|
|
20
|
+
}
|
|
21
|
+
if (name === 'telemetry_store') {
|
|
22
|
+
return ['clickhouse', { enabled: spec.enabled !== false }];
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`system document has no module ${name}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps modules onto chart product keys.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} profile - server profile
|
|
31
|
+
* @param {string|undefined} secret - state-store Secret name
|
|
32
|
+
* @returns {object} postgres, rabbitmq, and clickhouse fragments
|
|
33
|
+
* @example
|
|
34
|
+
* bindModules({ modules: { telemetry_store: { enabled: true } } }, 'pg');
|
|
35
|
+
*/
|
|
36
|
+
export function bindModules(profile, secret) {
|
|
37
|
+
const values = {};
|
|
38
|
+
Object.keys(profile.modules || {}).forEach((name) => {
|
|
39
|
+
const [key, value] = bindOne(name, profile.modules[name], secret);
|
|
40
|
+
values[key] = value;
|
|
41
|
+
});
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Opaque fields of an object record.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} server - selected server
|
|
49
|
+
* @param {string} path - object path
|
|
50
|
+
* @returns {object} extra fields
|
|
51
|
+
* @example
|
|
52
|
+
* recordFor({ objects: ['mill/unit'] }, 'mill/unit');
|
|
53
|
+
*/
|
|
54
|
+
function recordFor(server, path) {
|
|
55
|
+
const found = (server.objects || []).find((entry) => {
|
|
56
|
+
return typeof entry === 'object' && entry !== null && (entry.id === path || entry.path === path);
|
|
57
|
+
});
|
|
58
|
+
return found || {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Local fan-in joined with the server's objects.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} server - selected server
|
|
65
|
+
* @returns {object[]} machine records
|
|
66
|
+
* @example
|
|
67
|
+
* machinesOf({ fan_in: [], id: 'cell' });
|
|
68
|
+
*/
|
|
69
|
+
export function machinesOf(server) {
|
|
70
|
+
const rows = [];
|
|
71
|
+
(server.fan_in || []).forEach((flow) => {
|
|
72
|
+
if (!isAddress(flow.from)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const path = objectPath(server, flow.name);
|
|
76
|
+
const row = {
|
|
77
|
+
from: flow.from,
|
|
78
|
+
id: path.split('/').at(-1),
|
|
79
|
+
name: flow.name
|
|
80
|
+
};
|
|
81
|
+
if (flow.mapping) {
|
|
82
|
+
row.mapping = flow.mapping;
|
|
83
|
+
}
|
|
84
|
+
const extra = recordFor(server, path);
|
|
85
|
+
Object.keys(extra).forEach((key) => {
|
|
86
|
+
if (key !== 'id' && key !== 'path') {
|
|
87
|
+
row[key] = extra[key];
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
rows.push(row);
|
|
91
|
+
});
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Plant API URL of a source, when that source publishes one.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} source - source server
|
|
99
|
+
* @param {string|undefined} api - service the edge names
|
|
100
|
+
* @returns {string} plant URL, or empty when the source publishes none
|
|
101
|
+
* @example
|
|
102
|
+
* plantUrl({ api: [{ id: 'plant', url: 'http://192.0.2.10/api/v1' }], id: 'cell' });
|
|
103
|
+
*/
|
|
104
|
+
function plantUrl(source, api) {
|
|
105
|
+
const listed = source.api || [];
|
|
106
|
+
if (api && !listed.some((item) => {
|
|
107
|
+
return item.id === api;
|
|
108
|
+
})) {
|
|
109
|
+
throw new Error(`system document server ${source.id} provides no api ${api}`);
|
|
110
|
+
}
|
|
111
|
+
if (!api && listed.length > 1) {
|
|
112
|
+
throw new Error(`system document server ${source.id} must name one api`);
|
|
113
|
+
}
|
|
114
|
+
const plant = listed.find((item) => {
|
|
115
|
+
return item.id === 'plant';
|
|
116
|
+
});
|
|
117
|
+
return plant ? plant.url : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Sites for edges aimed at one server.
|
|
122
|
+
*
|
|
123
|
+
* `baseUrl` is the source plant API. A source with no plant API is skipped.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} document - expanded document
|
|
126
|
+
* @param {object[]} edges - directed edges
|
|
127
|
+
* @param {object} server - receiver
|
|
128
|
+
* @returns {object[]} site entries
|
|
129
|
+
* @example
|
|
130
|
+
* routesFor({ servers: [] }, [], { id: 'archive' });
|
|
131
|
+
*/
|
|
132
|
+
export function routesFor(document, edges, server) {
|
|
133
|
+
const routes = [];
|
|
134
|
+
edges.filter((edge) => {
|
|
135
|
+
return edge.to === server.id;
|
|
136
|
+
}).forEach((edge) => {
|
|
137
|
+
const source = serverById(document, edge.from);
|
|
138
|
+
const url = plantUrl(source, edge.api);
|
|
139
|
+
if (url.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const machines = [];
|
|
143
|
+
edge.objects.forEach((path) => {
|
|
144
|
+
const id = path.split('/').at(-1);
|
|
145
|
+
if (!machines.includes(id)) {
|
|
146
|
+
machines.push(id);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
routes.push({ baseUrl: url, machines });
|
|
150
|
+
});
|
|
151
|
+
return routes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Bus bindings a fan-out opens on another cluster.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} document - expanded document
|
|
158
|
+
* @param {object} server - sender
|
|
159
|
+
* @returns {{names: string[], url: string}[]} bus bindings
|
|
160
|
+
* @example
|
|
161
|
+
* busesFor({ profiles: {}, servers: [] }, { fan_out: [], profile: { id: 'cell' } });
|
|
162
|
+
*/
|
|
163
|
+
export function busesFor(document, server) {
|
|
164
|
+
const mine = clusterOf(document, server);
|
|
165
|
+
const groups = new Map();
|
|
166
|
+
(server.fan_out || []).forEach((flow) => {
|
|
167
|
+
const dest = serverById(document, flow.to);
|
|
168
|
+
const other = clusterOf(document, dest);
|
|
169
|
+
if (other === mine) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!groups.has(other)) {
|
|
173
|
+
const bus = (dest.api || []).find((item) => {
|
|
174
|
+
return item.id === 'bus';
|
|
175
|
+
});
|
|
176
|
+
if (!bus) {
|
|
177
|
+
throw new Error(`system document server ${dest.id} provides no api bus`);
|
|
178
|
+
}
|
|
179
|
+
groups.set(other, { names: [], url: bus.url });
|
|
180
|
+
}
|
|
181
|
+
groups.get(other).names.push(flow.name);
|
|
182
|
+
});
|
|
183
|
+
return Array.from(groups.values());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Apps of a profile other than scada.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} profile - resolved profile
|
|
190
|
+
* @returns {object[]} pass-through apps
|
|
191
|
+
* @example
|
|
192
|
+
* passedApps({ apps: { frontend: { image: 'example.invalid/hmi:1' } } });
|
|
193
|
+
*/
|
|
194
|
+
export function passedApps(profile) {
|
|
195
|
+
return Object.keys(profile.apps || {}).filter((name) => {
|
|
196
|
+
return name !== 'scada';
|
|
197
|
+
}).map((name) => {
|
|
198
|
+
return { ...profile.apps[name], name };
|
|
199
|
+
});
|
|
200
|
+
}
|