@yarkivaev/scada 2.3.90 → 2.3.113

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.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/charts/scada/Chart.yaml +6 -0
  3. package/charts/scada/templates/_helpers.tpl +88 -0
  4. package/charts/scada/templates/clickhouse.yaml +62 -0
  5. package/charts/scada/templates/collector.yaml +114 -0
  6. package/charts/scada/templates/postgres.yaml +67 -0
  7. package/charts/scada/templates/rabbitmq.yaml +174 -0
  8. package/charts/scada/templates/site-server.yaml +107 -0
  9. package/charts/scada/templates/supervisor-sink.yaml +62 -0
  10. package/charts/scada/values.yaml +40 -0
  11. package/index.js +10 -0
  12. package/package.json +3 -1
  13. package/src/application/deploy/appReleases.js +50 -0
  14. package/src/application/deploy/applyIncludes.js +31 -0
  15. package/src/application/deploy/expand.js +49 -0
  16. package/src/application/deploy/expandDevices.js +135 -0
  17. package/src/application/deploy/expandFlows.js +281 -0
  18. package/src/application/deploy/expandNamespace.js +193 -0
  19. package/src/application/deploy/expandPattern.js +207 -0
  20. package/src/application/deploy/expandProfiles.js +70 -0
  21. package/src/application/deploy/frozen.js +23 -0
  22. package/src/application/deploy/mergeDocuments.js +98 -0
  23. package/src/application/deploy/paths.js +86 -0
  24. package/src/application/deploy/pointPaths.js +20 -0
  25. package/src/application/deploy/readDocument.js +169 -0
  26. package/src/application/deploy/releaseEdges.js +185 -0
  27. package/src/application/deploy/releaseNodes.js +118 -0
  28. package/src/application/deploy/releaseValues.js +200 -0
  29. package/src/application/deploy/releases.js +115 -0
  30. package/src/application/deploy/withCollectors.js +28 -0
  31. package/src/bin/modbus-to-mqtt.js +57 -0
  32. package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
  33. package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
  34. package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
  35. 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
+ }