@yarkivaev/scada 2.3.114 → 2.3.115
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 +41 -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 +4 -2
- package/src/application/deploy/collectorProcesses.js +42 -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 +60 -0
- package/src/infrastructure/messaging/rabbitmq/definitions.js +24 -0
- package/src/infrastructure/messaging/rabbitmq/reconcile.js +104 -0
package/README.md
CHANGED
|
@@ -286,3 +286,44 @@ The scada chart declares the queue and applies an ingest-only dead-letter policy
|
|
|
286
286
|
without changing existing queue arguments. The profile `env` is passed to both
|
|
287
287
|
site-server and supervisor-sink. DLQ records retain the original body and RabbitMQ
|
|
288
288
|
`x-death` metadata for inspection and deliberate replay; they are not auto-expired.
|
|
289
|
+
|
|
290
|
+
### Deploy a node with named processes
|
|
291
|
+
|
|
292
|
+
A profile can expose deployment coordinates in `target`, set
|
|
293
|
+
`apps.scada.site.enabled` and `apps.scada.sink.enabled` independently, and
|
|
294
|
+
provide RabbitMQ chart settings in `modules.message_bus.chart`. The
|
|
295
|
+
`releases(document, cluster)` projection returns the target and chart values.
|
|
296
|
+
The default profile still renders the existing site and sink.
|
|
297
|
+
|
|
298
|
+
`subscriptions(expandedDocument, sourceId)` returns the outgoing server
|
|
299
|
+
subscriptions as `{ from, to, name, api? }`. It combines sender `fan_out` and
|
|
300
|
+
receiver `fan_in` declarations, keeping each expanded stream path and any
|
|
301
|
+
explicit service selected by the receiver. Declaring the same subscription on
|
|
302
|
+
both sides produces a single row.
|
|
303
|
+
|
|
304
|
+
Use `collectorProcesses(machines, describe, project)` to derive workloads from
|
|
305
|
+
the selected server's source rows. For each row, `describe(row)` returns a
|
|
306
|
+
process name, command and optional environment and resources. Rows with the
|
|
307
|
+
same name must describe the same process. `project(descriptor, rows)` converts
|
|
308
|
+
its sources into adapter-specific environment variables and host devices. Process
|
|
309
|
+
order follows the first occurrence of each name in the source rows.
|
|
310
|
+
The packaged `charts/scada` chart renders the resulting `processes` as
|
|
311
|
+
named Deployments. A configured broker claim name selects a standalone PVC.
|
|
312
|
+
The default storage mode uses StatefulSet claim templates.
|
|
313
|
+
|
|
314
|
+
`executeRelease(plan, action)` renders the chart or runs check, diff, adopt
|
|
315
|
+
and apply against the explicitly supplied Kubernetes context and namespace.
|
|
316
|
+
During `adopt`, the legacy client-side manager migrates the resources first.
|
|
317
|
+
The release's server-side manager then applies the same rendered manifest before
|
|
318
|
+
the executor waits for the workloads. This restores declared fields that a
|
|
319
|
+
legacy three-way merge can drop, such as an environment entry whose name was
|
|
320
|
+
duplicated in the old last-applied annotation. Each apply gets its own server
|
|
321
|
+
dry-run. `check-adoption` previews the client-side phase only. The server-side
|
|
322
|
+
phase must run against the migrated state. An ownership conflict stops adoption
|
|
323
|
+
before rollout waits and the afterReady hook. Resolve the field ownership and
|
|
324
|
+
rerun `apply` after the client-side migration has completed.
|
|
325
|
+
A caller can supply prerequisites, rollout resources and an afterReady hook.
|
|
326
|
+
The RabbitMQ definitions builder and management reconciler are exported as
|
|
327
|
+
`@yarkivaev/scada/rabbitmq/definitions` and `@yarkivaev/scada/rabbitmq/reconcile`.
|
|
328
|
+
The caller supplies the vhost, connection settings and ownership policy for
|
|
329
|
+
retired routes. Reconciliation retains stored queues.
|
package/charts/scada/Chart.yaml
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
{{- define "scada.prefix" -}}
|
|
2
|
+
{{- default .Release.Name .Values.prefix -}}
|
|
3
|
+
{{- end }}
|
|
4
|
+
|
|
1
5
|
{{- define "scada.labels" -}}
|
|
2
6
|
app.kubernetes.io/name: scada
|
|
3
7
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
@@ -69,7 +73,7 @@ volumeMode: Filesystem
|
|
|
69
73
|
{{- end }}
|
|
70
74
|
{{- if .Values.rabbitmq.enabled }}
|
|
71
75
|
- name: STOMP_URL
|
|
72
|
-
value: {{ printf "stomp://%s-rabbitmq:61613" .
|
|
76
|
+
value: {{ printf "stomp://%s-rabbitmq:61613" (include "scada.prefix" .) | quote }}
|
|
73
77
|
- name: STOMP_LOGIN
|
|
74
78
|
value: guest
|
|
75
79
|
- name: STOMP_PASSCODE
|
|
@@ -77,12 +81,16 @@ volumeMode: Filesystem
|
|
|
77
81
|
- name: STOMP_HOST
|
|
78
82
|
value: /
|
|
79
83
|
- name: AMQP_URL
|
|
80
|
-
value: {{ printf "amqp://guest:guest@%s-rabbitmq:5672" .
|
|
84
|
+
value: {{ printf "amqp://guest:guest@%s-rabbitmq:5672" (include "scada.prefix" .) | quote }}
|
|
81
85
|
{{- end }}
|
|
82
86
|
{{- if .Values.clickhouse.enabled }}
|
|
83
87
|
- name: CLICKHOUSE_HOST
|
|
84
|
-
value: {{ printf "%s-clickhouse" .
|
|
88
|
+
value: {{ printf "%s-clickhouse" (include "scada.prefix" .) | quote }}
|
|
85
89
|
- name: CLICKHOUSE_URL
|
|
86
|
-
value: {{ printf "http://%s-clickhouse:8123" .
|
|
90
|
+
value: {{ printf "http://%s-clickhouse:8123" (include "scada.prefix" .) | quote }}
|
|
87
91
|
{{- end }}
|
|
88
92
|
{{- end }}
|
|
93
|
+
|
|
94
|
+
{{- define "scada.brokerConfigName" -}}
|
|
95
|
+
{{- default (printf "%s-rabbitmq" (include "scada.prefix" .)) .Values.rabbitmq.configName -}}
|
|
96
|
+
{{- end }}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
apiVersion: v1
|
|
3
3
|
kind: Service
|
|
4
4
|
metadata:
|
|
5
|
-
name: {{ .
|
|
5
|
+
name: {{ include "scada.prefix" . }}-clickhouse
|
|
6
6
|
namespace: {{ .Release.Namespace }}
|
|
7
7
|
labels:
|
|
8
8
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -24,13 +24,13 @@ spec:
|
|
|
24
24
|
apiVersion: apps/v1
|
|
25
25
|
kind: StatefulSet
|
|
26
26
|
metadata:
|
|
27
|
-
name: {{ .
|
|
27
|
+
name: {{ include "scada.prefix" . }}-clickhouse
|
|
28
28
|
namespace: {{ .Release.Namespace }}
|
|
29
29
|
labels:
|
|
30
30
|
{{- include "scada.labels" . | nindent 4 }}
|
|
31
31
|
app: clickhouse
|
|
32
32
|
spec:
|
|
33
|
-
serviceName: {{ .
|
|
33
|
+
serviceName: {{ include "scada.prefix" . }}-clickhouse
|
|
34
34
|
replicas: 1
|
|
35
35
|
selector:
|
|
36
36
|
matchLabels:
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
apiVersion: apps/v1
|
|
54
54
|
kind: Deployment
|
|
55
55
|
metadata:
|
|
56
|
-
name: {{
|
|
56
|
+
name: {{ include "scada.prefix" $ }}-collector-{{ $slug }}
|
|
57
57
|
namespace: {{ $.Release.Namespace }}
|
|
58
58
|
labels:
|
|
59
59
|
{{- include "scada.labels" $ | nindent 4 }}
|
|
@@ -97,7 +97,7 @@ spec:
|
|
|
97
97
|
value: {{ $maps | toJson | quote }}
|
|
98
98
|
{{- if $.Values.rabbitmq.enabled }}
|
|
99
99
|
- name: MQTT_URL
|
|
100
|
-
value: {{ printf "mqtt://%s-rabbitmq:1883"
|
|
100
|
+
value: {{ printf "mqtt://%s-rabbitmq:1883" (include "scada.prefix" $) | quote }}
|
|
101
101
|
{{- end }}
|
|
102
102
|
resources:
|
|
103
103
|
{{- toYaml $.Values.resources | nindent 12 }}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
apiVersion: v1
|
|
3
3
|
kind: Service
|
|
4
4
|
metadata:
|
|
5
|
-
name: {{ .
|
|
5
|
+
name: {{ include "scada.prefix" . }}-postgres
|
|
6
6
|
namespace: {{ .Release.Namespace }}
|
|
7
7
|
labels:
|
|
8
8
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -18,13 +18,13 @@ spec:
|
|
|
18
18
|
apiVersion: apps/v1
|
|
19
19
|
kind: StatefulSet
|
|
20
20
|
metadata:
|
|
21
|
-
name: {{ .
|
|
21
|
+
name: {{ include "scada.prefix" . }}-postgres
|
|
22
22
|
namespace: {{ .Release.Namespace }}
|
|
23
23
|
labels:
|
|
24
24
|
{{- include "scada.labels" . | nindent 4 }}
|
|
25
25
|
app: postgres
|
|
26
26
|
spec:
|
|
27
|
-
serviceName: {{ .
|
|
27
|
+
serviceName: {{ include "scada.prefix" . }}-postgres
|
|
28
28
|
replicas: 1
|
|
29
29
|
selector:
|
|
30
30
|
matchLabels:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{{- $names := dict }}
|
|
2
|
+
{{- $devices := dict }}
|
|
3
|
+
{{- range .Values.processes }}
|
|
4
|
+
{{- $name := required "process name is required" .name }}
|
|
5
|
+
{{- if hasKey $names $name }}
|
|
6
|
+
{{- fail (printf "duplicate process name %s" $name) }}
|
|
7
|
+
{{- end }}
|
|
8
|
+
{{- $_ := set $names $name true }}
|
|
9
|
+
{{- range .hostDevices }}
|
|
10
|
+
{{- if hasKey $devices . }}
|
|
11
|
+
{{- fail (printf "processes %s and %s both claim %s" $name (get $devices .) .) }}
|
|
12
|
+
{{- end }}
|
|
13
|
+
{{- $_ := set $devices . $name }}
|
|
14
|
+
{{- end }}
|
|
15
|
+
---
|
|
16
|
+
apiVersion: apps/v1
|
|
17
|
+
kind: Deployment
|
|
18
|
+
metadata:
|
|
19
|
+
name: {{ include "scada.prefix" $ }}-{{ $name }}
|
|
20
|
+
namespace: {{ $.Release.Namespace }}
|
|
21
|
+
labels:
|
|
22
|
+
{{- include "scada.labels" $ | nindent 4 }}
|
|
23
|
+
app: {{ $name }}
|
|
24
|
+
{{- with $.Values.workloadLabels }}
|
|
25
|
+
{{- toYaml . | nindent 4 }}
|
|
26
|
+
{{- end }}
|
|
27
|
+
spec:
|
|
28
|
+
replicas: 1
|
|
29
|
+
strategy:
|
|
30
|
+
type: Recreate
|
|
31
|
+
rollingUpdate: null
|
|
32
|
+
selector:
|
|
33
|
+
matchLabels:
|
|
34
|
+
{{- include "scada.match" (dict "app" $name "ctx" $) | nindent 6 }}
|
|
35
|
+
template:
|
|
36
|
+
metadata:
|
|
37
|
+
labels:
|
|
38
|
+
{{- include "scada.labels" $ | nindent 8 }}
|
|
39
|
+
app: {{ $name }}
|
|
40
|
+
{{- with $.Values.workloadLabels }}
|
|
41
|
+
{{- toYaml . | nindent 8 }}
|
|
42
|
+
{{- end }}
|
|
43
|
+
{{- if and $.Values.rabbitmq.enabled $.Values.rabbitmq.rolloutOnConfigChange }}
|
|
44
|
+
annotations:
|
|
45
|
+
checksum/broker: {{ include (print $.Template.BasePath "/rabbitmq-config.yaml") $ | sha256sum }}
|
|
46
|
+
{{- end }}
|
|
47
|
+
spec:
|
|
48
|
+
{{- with $.Values.pullSecret }}
|
|
49
|
+
imagePullSecrets:
|
|
50
|
+
- name: {{ . }}
|
|
51
|
+
{{- end }}
|
|
52
|
+
{{- with .podSecurityContext }}
|
|
53
|
+
securityContext:
|
|
54
|
+
{{- toYaml . | nindent 8 }}
|
|
55
|
+
{{- end }}
|
|
56
|
+
containers:
|
|
57
|
+
- name: {{ $name }}
|
|
58
|
+
image: {{ required "process image is required" (default $.Values.image .image) | quote }}
|
|
59
|
+
imagePullPolicy: IfNotPresent
|
|
60
|
+
command: {{ required "process command is required" .command | toJson }}
|
|
61
|
+
{{- if .hostDevices }}
|
|
62
|
+
securityContext:
|
|
63
|
+
privileged: true
|
|
64
|
+
volumeMounts:
|
|
65
|
+
{{- range $index, $path := .hostDevices }}
|
|
66
|
+
- name: serial-{{ add1 $index }}
|
|
67
|
+
mountPath: {{ $path | quote }}
|
|
68
|
+
{{- end }}
|
|
69
|
+
{{- end }}
|
|
70
|
+
{{- with .env }}
|
|
71
|
+
env:
|
|
72
|
+
{{- range $key, $value := . }}
|
|
73
|
+
- name: {{ $key }}
|
|
74
|
+
value: {{ $value | quote }}
|
|
75
|
+
{{- end }}
|
|
76
|
+
{{- end }}
|
|
77
|
+
resources:
|
|
78
|
+
{{- toYaml (default $.Values.resources .resources) | nindent 12 }}
|
|
79
|
+
{{- with .hostDevices }}
|
|
80
|
+
volumes:
|
|
81
|
+
{{- range $index, $path := . }}
|
|
82
|
+
- name: serial-{{ add1 $index }}
|
|
83
|
+
hostPath:
|
|
84
|
+
path: {{ $path | quote }}
|
|
85
|
+
type: CharDevice
|
|
86
|
+
{{- end }}
|
|
87
|
+
{{- end }}
|
|
88
|
+
{{- end }}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{{- if .Values.rabbitmq.enabled }}
|
|
2
|
+
{{- $ingest := list -}}
|
|
3
|
+
{{- $outgoing := list -}}
|
|
4
|
+
{{- range .Values.collectors }}
|
|
5
|
+
{{- if .path }}
|
|
6
|
+
{{- $ingest = append $ingest (include "scada.routingKey" .path | trim) -}}
|
|
7
|
+
{{- end }}
|
|
8
|
+
{{- end }}
|
|
9
|
+
{{- range $item := concat (.Values.buses | default (list)) (.Values.bus | default (list)) }}
|
|
10
|
+
{{- range $name := ($item.names | default (list)) }}
|
|
11
|
+
{{- $routed := include "scada.routingKey" $name | trim -}}
|
|
12
|
+
{{- $ingest = append $ingest $routed -}}
|
|
13
|
+
{{- $outgoing = append $outgoing $routed -}}
|
|
14
|
+
{{- end }}
|
|
15
|
+
{{- end }}
|
|
16
|
+
{{- $outgoing = $outgoing | uniq -}}
|
|
17
|
+
{{- if $ingest }}
|
|
18
|
+
{{- $ingest = $ingest | uniq -}}
|
|
19
|
+
{{- else }}
|
|
20
|
+
{{- $ingest = list "#" -}}
|
|
21
|
+
{{- end }}
|
|
22
|
+
{{- $exchange := list
|
|
23
|
+
(dict "name" "scada.alerts" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
24
|
+
(dict "name" "scada.segments" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
25
|
+
(dict "name" "scada.telemetry" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
26
|
+
(dict "name" "scada.operations" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
27
|
+
(dict "name" "scada.user_decisions" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
28
|
+
-}}
|
|
29
|
+
{{- $queue := list
|
|
30
|
+
(dict "name" "scada.segments.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
31
|
+
(dict "name" "scada.telemetry.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
32
|
+
(dict "name" "scada.operations.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
33
|
+
(dict "name" "scada.user_decisions" "vhost" "/" "durable" true "auto_delete" false)
|
|
34
|
+
-}}
|
|
35
|
+
{{- if $outgoing }}
|
|
36
|
+
{{- $queue = append $queue (dict "name" "scada.telemetry.outbox" "vhost" "/" "durable" true "auto_delete" false) -}}
|
|
37
|
+
{{- end }}
|
|
38
|
+
{{- $policies := list -}}
|
|
39
|
+
{{- with .Values.rabbitmq.deadLetter }}
|
|
40
|
+
{{- $queue = append $queue (dict "name" .queue "vhost" "/" "durable" true "auto_delete" false) -}}
|
|
41
|
+
{{- $policies = append $policies (dict "name" "ingest-dlq" "vhost" "/" "pattern" "^scada\\.(segments|operations|telemetry)\\.ingest$" "apply-to" "queues" "priority" 10 "definition" (dict "dead-letter-exchange" "" "dead-letter-routing-key" .queue)) -}}
|
|
42
|
+
{{- end }}
|
|
43
|
+
{{- $binding := list -}}
|
|
44
|
+
{{- range $key := $ingest }}
|
|
45
|
+
{{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.ingest" "destination_type" "queue" "routing_key" $key) -}}
|
|
46
|
+
{{- end }}
|
|
47
|
+
{{- range $pair := (list
|
|
48
|
+
(dict "source" "scada.operations" "destination" "scada.operations.ingest")
|
|
49
|
+
(dict "source" "scada.segments" "destination" "scada.segments.ingest")
|
|
50
|
+
(dict "source" "scada.user_decisions" "destination" "scada.user_decisions")) }}
|
|
51
|
+
{{- $binding = append $binding (dict "source" $pair.source "vhost" "/" "destination" $pair.destination "destination_type" "queue" "routing_key" "#") -}}
|
|
52
|
+
{{- end }}
|
|
53
|
+
{{- range $key := $outgoing }}
|
|
54
|
+
{{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.outbox" "destination_type" "queue" "routing_key" $key) -}}
|
|
55
|
+
{{- end }}
|
|
56
|
+
{{- $definitions := dict
|
|
57
|
+
"vhosts" (list (dict "name" "/"))
|
|
58
|
+
"users" (list (dict "name" "guest" "password" "guest" "tags" (list "administrator")))
|
|
59
|
+
"permissions" (list (dict "user" "guest" "vhost" "/" "configure" ".*" "write" ".*" "read" ".*"))
|
|
60
|
+
"exchanges" $exchange
|
|
61
|
+
"queues" $queue
|
|
62
|
+
"policies" $policies
|
|
63
|
+
"bindings" $binding
|
|
64
|
+
-}}
|
|
65
|
+
apiVersion: v1
|
|
66
|
+
kind: ConfigMap
|
|
67
|
+
metadata:
|
|
68
|
+
name: {{ include "scada.brokerConfigName" . }}
|
|
69
|
+
namespace: {{ .Release.Namespace }}
|
|
70
|
+
labels:
|
|
71
|
+
{{- include "scada.labels" . | nindent 4 }}
|
|
72
|
+
app: rabbitmq
|
|
73
|
+
data:
|
|
74
|
+
enabled_plugins: |
|
|
75
|
+
[{{ join ", " .Values.rabbitmq.plugins }}].
|
|
76
|
+
rabbitmq.conf: |
|
|
77
|
+
load_definitions = /etc/rabbitmq/definitions.json
|
|
78
|
+
mqtt.listeners.tcp.default = 1883
|
|
79
|
+
mqtt.allow_anonymous = true
|
|
80
|
+
mqtt.vhost = /
|
|
81
|
+
mqtt.exchange = scada.telemetry
|
|
82
|
+
{{- range $key, $value := .Values.rabbitmq.extraConfig }}
|
|
83
|
+
{{ $key }} = {{ $value }}
|
|
84
|
+
{{- end }}
|
|
85
|
+
definitions.json: |
|
|
86
|
+
{{- if ne .Values.rabbitmq.definitions nil }}
|
|
87
|
+
{{ .Values.rabbitmq.definitions | toPrettyJson | nindent 4 }}
|
|
88
|
+
{{- else }}
|
|
89
|
+
{{ $definitions | toPrettyJson | nindent 4 }}
|
|
90
|
+
{{- end }}
|
|
91
|
+
{{- end }}
|
|
@@ -1,91 +1,8 @@
|
|
|
1
1
|
{{- if .Values.rabbitmq.enabled }}
|
|
2
|
-
{{- $ingest := list -}}
|
|
3
|
-
{{- $outgoing := list -}}
|
|
4
|
-
{{- range .Values.collectors }}
|
|
5
|
-
{{- if .path }}
|
|
6
|
-
{{- $ingest = append $ingest (include "scada.routingKey" .path | trim) -}}
|
|
7
|
-
{{- end }}
|
|
8
|
-
{{- end }}
|
|
9
|
-
{{- range $item := concat (.Values.buses | default (list)) (.Values.bus | default (list)) }}
|
|
10
|
-
{{- range $name := ($item.names | default (list)) }}
|
|
11
|
-
{{- $routed := include "scada.routingKey" $name | trim -}}
|
|
12
|
-
{{- $ingest = append $ingest $routed -}}
|
|
13
|
-
{{- $outgoing = append $outgoing $routed -}}
|
|
14
|
-
{{- end }}
|
|
15
|
-
{{- end }}
|
|
16
|
-
{{- $outgoing = $outgoing | uniq -}}
|
|
17
|
-
{{- if $ingest }}
|
|
18
|
-
{{- $ingest = $ingest | uniq -}}
|
|
19
|
-
{{- else }}
|
|
20
|
-
{{- $ingest = list "#" -}}
|
|
21
|
-
{{- end }}
|
|
22
|
-
{{- $exchange := list
|
|
23
|
-
(dict "name" "scada.alerts" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
24
|
-
(dict "name" "scada.segments" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
25
|
-
(dict "name" "scada.telemetry" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
26
|
-
(dict "name" "scada.operations" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
27
|
-
(dict "name" "scada.user_decisions" "vhost" "/" "type" "topic" "durable" true "auto_delete" false)
|
|
28
|
-
-}}
|
|
29
|
-
{{- $queue := list
|
|
30
|
-
(dict "name" "scada.segments.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
31
|
-
(dict "name" "scada.telemetry.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
32
|
-
(dict "name" "scada.operations.ingest" "vhost" "/" "durable" true "auto_delete" false)
|
|
33
|
-
(dict "name" "scada.user_decisions" "vhost" "/" "durable" true "auto_delete" false)
|
|
34
|
-
-}}
|
|
35
|
-
{{- if $outgoing }}
|
|
36
|
-
{{- $queue = append $queue (dict "name" "scada.telemetry.outbox" "vhost" "/" "durable" true "auto_delete" false) -}}
|
|
37
|
-
{{- end }}
|
|
38
|
-
{{- $policies := list -}}
|
|
39
|
-
{{- with .Values.rabbitmq.deadLetter }}
|
|
40
|
-
{{- $queue = append $queue (dict "name" .queue "vhost" "/" "durable" true "auto_delete" false) -}}
|
|
41
|
-
{{- $policies = append $policies (dict "name" "ingest-dlq" "vhost" "/" "pattern" "^scada\\.(segments|operations|telemetry)\\.ingest$" "apply-to" "queues" "priority" 10 "definition" (dict "dead-letter-exchange" "" "dead-letter-routing-key" .queue)) -}}
|
|
42
|
-
{{- end }}
|
|
43
|
-
{{- $binding := list -}}
|
|
44
|
-
{{- range $key := $ingest }}
|
|
45
|
-
{{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.ingest" "destination_type" "queue" "routing_key" $key) -}}
|
|
46
|
-
{{- end }}
|
|
47
|
-
{{- range $pair := (list
|
|
48
|
-
(dict "source" "scada.operations" "destination" "scada.operations.ingest")
|
|
49
|
-
(dict "source" "scada.segments" "destination" "scada.segments.ingest")
|
|
50
|
-
(dict "source" "scada.user_decisions" "destination" "scada.user_decisions")) }}
|
|
51
|
-
{{- $binding = append $binding (dict "source" $pair.source "vhost" "/" "destination" $pair.destination "destination_type" "queue" "routing_key" "#") -}}
|
|
52
|
-
{{- end }}
|
|
53
|
-
{{- range $key := $outgoing }}
|
|
54
|
-
{{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.outbox" "destination_type" "queue" "routing_key" $key) -}}
|
|
55
|
-
{{- end }}
|
|
56
|
-
{{- $definitions := dict
|
|
57
|
-
"vhosts" (list (dict "name" "/"))
|
|
58
|
-
"users" (list (dict "name" "guest" "password" "guest" "tags" (list "administrator")))
|
|
59
|
-
"permissions" (list (dict "user" "guest" "vhost" "/" "configure" ".*" "write" ".*" "read" ".*"))
|
|
60
|
-
"exchanges" $exchange
|
|
61
|
-
"queues" $queue
|
|
62
|
-
"policies" $policies
|
|
63
|
-
"bindings" $binding
|
|
64
|
-
-}}
|
|
65
|
-
apiVersion: v1
|
|
66
|
-
kind: ConfigMap
|
|
67
|
-
metadata:
|
|
68
|
-
name: {{ .Release.Name }}-rabbitmq
|
|
69
|
-
namespace: {{ .Release.Namespace }}
|
|
70
|
-
labels:
|
|
71
|
-
{{- include "scada.labels" . | nindent 4 }}
|
|
72
|
-
app: rabbitmq
|
|
73
|
-
data:
|
|
74
|
-
enabled_plugins: |
|
|
75
|
-
[rabbitmq_stomp, rabbitmq_mqtt, rabbitmq_management].
|
|
76
|
-
rabbitmq.conf: |
|
|
77
|
-
load_definitions = /etc/rabbitmq/definitions.json
|
|
78
|
-
mqtt.listeners.tcp.default = 1883
|
|
79
|
-
mqtt.allow_anonymous = true
|
|
80
|
-
mqtt.vhost = /
|
|
81
|
-
mqtt.exchange = scada.telemetry
|
|
82
|
-
definitions.json: |
|
|
83
|
-
{{ $definitions | toPrettyJson | nindent 4 }}
|
|
84
|
-
---
|
|
85
2
|
apiVersion: v1
|
|
86
3
|
kind: Service
|
|
87
4
|
metadata:
|
|
88
|
-
name: {{ .
|
|
5
|
+
name: {{ include "scada.prefix" . }}-rabbitmq
|
|
89
6
|
namespace: {{ .Release.Namespace }}
|
|
90
7
|
labels:
|
|
91
8
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -94,46 +11,30 @@ spec:
|
|
|
94
11
|
{{- $svc := .Values.rabbitmq.service | default dict }}
|
|
95
12
|
type: {{ $svc.type | default "ClusterIP" }}
|
|
96
13
|
ports:
|
|
97
|
-
- name
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{{
|
|
101
|
-
{{-
|
|
102
|
-
nodePort: {{ .
|
|
103
|
-
{{- end }}
|
|
104
|
-
{{- end }}
|
|
105
|
-
- name: mqtt
|
|
106
|
-
port: 1883
|
|
107
|
-
targetPort: mqtt
|
|
108
|
-
- name: stomp
|
|
109
|
-
port: 61613
|
|
110
|
-
targetPort: stomp
|
|
111
|
-
{{- with $svc.nodePorts }}
|
|
112
|
-
{{- if .stomp }}
|
|
113
|
-
nodePort: {{ .stomp }}
|
|
114
|
-
{{- end }}
|
|
115
|
-
{{- end }}
|
|
116
|
-
- name: management
|
|
117
|
-
port: 15672
|
|
118
|
-
targetPort: management
|
|
119
|
-
{{- with $svc.nodePorts }}
|
|
120
|
-
{{- if .management }}
|
|
121
|
-
nodePort: {{ .management }}
|
|
122
|
-
{{- end }}
|
|
14
|
+
{{- range $entry := list (dict "name" "amqp" "port" 5672) (dict "name" "mqtt" "port" 1883) (dict "name" "stomp" "port" 61613) (dict "name" "management" "port" 15672) }}
|
|
15
|
+
- name: {{ $entry.name }}
|
|
16
|
+
port: {{ $entry.port }}
|
|
17
|
+
targetPort: {{ ternary $entry.port $entry.name ($svc.numericTargetPorts | default false) }}
|
|
18
|
+
{{- with get ($svc.nodePorts | default dict) $entry.name }}
|
|
19
|
+
nodePort: {{ . }}
|
|
123
20
|
{{- end }}
|
|
21
|
+
{{- end }}
|
|
124
22
|
selector:
|
|
125
23
|
{{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 4 }}
|
|
126
24
|
---
|
|
127
25
|
apiVersion: apps/v1
|
|
128
26
|
kind: StatefulSet
|
|
129
27
|
metadata:
|
|
130
|
-
name: {{ .
|
|
28
|
+
name: {{ include "scada.prefix" . }}-rabbitmq
|
|
131
29
|
namespace: {{ .Release.Namespace }}
|
|
132
30
|
labels:
|
|
133
31
|
{{- include "scada.labels" . | nindent 4 }}
|
|
134
32
|
app: rabbitmq
|
|
33
|
+
{{- with .Values.workloadLabels }}
|
|
34
|
+
{{- toYaml . | nindent 4 }}
|
|
35
|
+
{{- end }}
|
|
135
36
|
spec:
|
|
136
|
-
serviceName: {{ .
|
|
37
|
+
serviceName: {{ include "scada.prefix" . }}-rabbitmq
|
|
137
38
|
replicas: 1
|
|
138
39
|
selector:
|
|
139
40
|
matchLabels:
|
|
@@ -143,10 +44,24 @@ spec:
|
|
|
143
44
|
labels:
|
|
144
45
|
{{- include "scada.labels" . | nindent 8 }}
|
|
145
46
|
{{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 8 }}
|
|
47
|
+
{{- with .Values.workloadLabels }}
|
|
48
|
+
{{- toYaml . | nindent 8 }}
|
|
49
|
+
{{- end }}
|
|
50
|
+
{{- if .Values.rabbitmq.rolloutOnConfigChange }}
|
|
51
|
+
annotations:
|
|
52
|
+
checksum/config: {{ include (print $.Template.BasePath "/rabbitmq-config.yaml") . | sha256sum }}
|
|
53
|
+
{{- end }}
|
|
146
54
|
spec:
|
|
55
|
+
{{- with .Values.pullSecret }}
|
|
56
|
+
imagePullSecrets:
|
|
57
|
+
- name: {{ . }}
|
|
58
|
+
{{- end }}
|
|
147
59
|
containers:
|
|
148
60
|
- name: rabbitmq
|
|
149
61
|
image: {{ .Values.rabbitmq.image | quote }}
|
|
62
|
+
{{- with .Values.rabbitmq.imagePullPolicy }}
|
|
63
|
+
imagePullPolicy: {{ . }}
|
|
64
|
+
{{- end }}
|
|
150
65
|
ports:
|
|
151
66
|
- name: amqp
|
|
152
67
|
containerPort: 5672
|
|
@@ -156,6 +71,18 @@ spec:
|
|
|
156
71
|
containerPort: 61613
|
|
157
72
|
- name: management
|
|
158
73
|
containerPort: 15672
|
|
74
|
+
{{- with .Values.rabbitmq.readinessProbe }}
|
|
75
|
+
readinessProbe:
|
|
76
|
+
{{- toYaml . | nindent 12 }}
|
|
77
|
+
{{- end }}
|
|
78
|
+
{{- with .Values.rabbitmq.livenessProbe }}
|
|
79
|
+
livenessProbe:
|
|
80
|
+
{{- toYaml . | nindent 12 }}
|
|
81
|
+
{{- end }}
|
|
82
|
+
{{- with .Values.rabbitmq.resources }}
|
|
83
|
+
resources:
|
|
84
|
+
{{- toYaml . | nindent 12 }}
|
|
85
|
+
{{- end }}
|
|
159
86
|
volumeMounts:
|
|
160
87
|
- name: config
|
|
161
88
|
mountPath: /etc/rabbitmq/enabled_plugins
|
|
@@ -171,10 +98,24 @@ spec:
|
|
|
171
98
|
volumes:
|
|
172
99
|
- name: config
|
|
173
100
|
configMap:
|
|
174
|
-
name: {{ .
|
|
101
|
+
name: {{ include "scada.brokerConfigName" . }}
|
|
102
|
+
{{- with .Values.rabbitmq.claimName }}
|
|
103
|
+
- name: data
|
|
104
|
+
persistentVolumeClaim:
|
|
105
|
+
claimName: {{ . }}
|
|
106
|
+
---
|
|
107
|
+
apiVersion: v1
|
|
108
|
+
kind: PersistentVolumeClaim
|
|
109
|
+
metadata:
|
|
110
|
+
name: {{ . }}
|
|
111
|
+
namespace: {{ $.Release.Namespace }}
|
|
112
|
+
spec:
|
|
113
|
+
{{- include "scada.claim" (dict "size" $.Values.rabbitmq.storage "class" $.Values.rabbitmq.storageClass) | nindent 2 }}
|
|
114
|
+
{{- else }}
|
|
175
115
|
volumeClaimTemplates:
|
|
176
116
|
- metadata:
|
|
177
117
|
name: data
|
|
178
118
|
spec:
|
|
179
119
|
{{- include "scada.claim" (dict "size" .Values.rabbitmq.storage "class" .Values.rabbitmq.storageClass) | nindent 8 }}
|
|
180
120
|
{{- end }}
|
|
121
|
+
{{- end }}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
{{- if .Values.site.enabled }}
|
|
1
2
|
apiVersion: apps/v1
|
|
2
3
|
kind: Deployment
|
|
3
4
|
metadata:
|
|
4
|
-
name: {{ .
|
|
5
|
+
name: {{ include "scada.prefix" . }}-site-server
|
|
5
6
|
namespace: {{ .Release.Namespace }}
|
|
6
7
|
labels:
|
|
7
8
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -70,7 +71,7 @@ spec:
|
|
|
70
71
|
apiVersion: v1
|
|
71
72
|
kind: Service
|
|
72
73
|
metadata:
|
|
73
|
-
name: {{ .
|
|
74
|
+
name: {{ include "scada.prefix" . }}-site-server
|
|
74
75
|
namespace: {{ .Release.Namespace }}
|
|
75
76
|
labels:
|
|
76
77
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -90,7 +91,7 @@ spec:
|
|
|
90
91
|
apiVersion: v1
|
|
91
92
|
kind: Service
|
|
92
93
|
metadata:
|
|
93
|
-
name: {{ .
|
|
94
|
+
name: {{ include "scada.prefix" . }}-site-server-plant-lan
|
|
94
95
|
namespace: {{ .Release.Namespace }}
|
|
95
96
|
labels:
|
|
96
97
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -105,3 +106,4 @@ spec:
|
|
|
105
106
|
selector:
|
|
106
107
|
{{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 4 }}
|
|
107
108
|
{{- end }}
|
|
109
|
+
{{- end }}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
{{- if .Values.sink.enabled }}
|
|
1
2
|
apiVersion: apps/v1
|
|
2
3
|
kind: Deployment
|
|
3
4
|
metadata:
|
|
4
|
-
name: {{ .
|
|
5
|
+
name: {{ include "scada.prefix" . }}-supervisor-sink
|
|
5
6
|
namespace: {{ .Release.Namespace }}
|
|
6
7
|
labels:
|
|
7
8
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -52,7 +53,7 @@ spec:
|
|
|
52
53
|
apiVersion: v1
|
|
53
54
|
kind: Service
|
|
54
55
|
metadata:
|
|
55
|
-
name: {{ .
|
|
56
|
+
name: {{ include "scada.prefix" . }}-supervisor-sink
|
|
56
57
|
namespace: {{ .Release.Namespace }}
|
|
57
58
|
labels:
|
|
58
59
|
{{- include "scada.labels" . | nindent 4 }}
|
|
@@ -64,3 +65,4 @@ spec:
|
|
|
64
65
|
targetPort: state
|
|
65
66
|
selector:
|
|
66
67
|
{{- include "scada.match" (dict "app" "supervisor-sink" "ctx" .) | nindent 4 }}
|
|
68
|
+
{{- end }}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"processes": {
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"required": ["name", "command"],
|
|
10
|
+
"properties": {
|
|
11
|
+
"name": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" },
|
|
12
|
+
"command": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
|
13
|
+
"env": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
|
14
|
+
"hostDevices": {
|
|
15
|
+
"type": "array", "uniqueItems": true,
|
|
16
|
+
"items": { "type": "string", "pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$" }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/charts/scada/values.yaml
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
role: edge
|
|
2
|
+
# Override the resource name prefix when adopting an existing installation.
|
|
3
|
+
prefix: ""
|
|
4
|
+
createNamespace: false
|
|
5
|
+
site:
|
|
6
|
+
enabled: true
|
|
7
|
+
sink:
|
|
8
|
+
enabled: true
|
|
9
|
+
# Labels for workloads that must stay outside immutable selectors.
|
|
10
|
+
workloadLabels: {}
|
|
11
|
+
# Named deployments with command, env, optional resources and hostDevices.
|
|
12
|
+
processes: []
|
|
2
13
|
image: ""
|
|
3
14
|
command: []
|
|
4
15
|
pullSecret: ""
|
|
@@ -22,6 +33,16 @@ postgres:
|
|
|
22
33
|
rabbitmq:
|
|
23
34
|
enabled: true
|
|
24
35
|
deadLetter: null
|
|
36
|
+
# A named standalone PVC replaces the default volumeClaimTemplates when set.
|
|
37
|
+
claimName: ""
|
|
38
|
+
configName: ""
|
|
39
|
+
rolloutOnConfigChange: false
|
|
40
|
+
definitions: null
|
|
41
|
+
plugins: [rabbitmq_stomp, rabbitmq_mqtt, rabbitmq_management]
|
|
42
|
+
extraConfig: {}
|
|
43
|
+
resources: {}
|
|
44
|
+
readinessProbe: {}
|
|
45
|
+
livenessProbe: {}
|
|
25
46
|
image: rabbitmq:3.13-management
|
|
26
47
|
storage: 5Gi
|
|
27
48
|
storageClass: ""
|
package/index.js
CHANGED
|
@@ -96,9 +96,12 @@ export { default as analyticsMcp } from './src/application/mcp/analyticsMcp.js';
|
|
|
96
96
|
export { default as expand } from './src/application/deploy/expand.js';
|
|
97
97
|
export { default as readDocument } from './src/application/deploy/readDocument.js';
|
|
98
98
|
export { default as mergeDocuments } from './src/application/deploy/mergeDocuments.js';
|
|
99
|
+
export { default as subscriptions } from './src/application/deploy/subscriptions.js';
|
|
99
100
|
export { default as releases } from './src/application/deploy/releases.js';
|
|
100
101
|
export { default as paths } from './src/application/deploy/paths.js';
|
|
101
102
|
export { default as appReleases } from './src/application/deploy/appReleases.js';
|
|
102
103
|
export { default as withCollectors } from './src/application/deploy/withCollectors.js';
|
|
103
104
|
export { default as pointPaths } from './src/application/deploy/pointPaths.js';
|
|
104
105
|
export { clusterOf, serverFor } from './src/application/deploy/releaseNodes.js';
|
|
106
|
+
export { default as executeRelease } from './src/infrastructure/deploy/executeRelease.js';
|
|
107
|
+
export { default as collectorProcesses } from './src/application/deploy/collectorProcesses.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.115",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"./client": "./src/infrastructure/client/index.js",
|
|
14
14
|
"./stateDataFake": "./src/infrastructure/persistence/stateDataFromMemory.js",
|
|
15
15
|
"./startTestEdgeApi": "./src/infrastructure/http/edge/startTestEdgeApi.js",
|
|
16
|
-
"./package.json": "./package.json"
|
|
16
|
+
"./package.json": "./package.json",
|
|
17
|
+
"./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
|
|
18
|
+
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js"
|
|
17
19
|
},
|
|
18
20
|
"bin": {
|
|
19
21
|
"modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
|
|
3
|
+
/** Group rows in first-seen order, keeping source order within each process. */
|
|
4
|
+
function grouped(machines, describe) {
|
|
5
|
+
const groups = new Map();
|
|
6
|
+
const claimed = new Set();
|
|
7
|
+
for (const row of machines) {
|
|
8
|
+
if (claimed.has(row.name)) { throw new Error(`duplicate source ${row.name}`); }
|
|
9
|
+
claimed.add(row.name);
|
|
10
|
+
const descriptor = describe(row);
|
|
11
|
+
if (typeof descriptor?.name !== 'string' || !descriptor.name.trim()) {
|
|
12
|
+
throw new Error(`unassigned source ${row.name}`);
|
|
13
|
+
}
|
|
14
|
+
const group = groups.get(descriptor.name) || { descriptor, rows: [] };
|
|
15
|
+
if (!isDeepStrictEqual(group.descriptor, descriptor)) {
|
|
16
|
+
throw new Error(`conflicting descriptions for process ${descriptor.name}`);
|
|
17
|
+
}
|
|
18
|
+
group.rows.push(row);
|
|
19
|
+
groups.set(descriptor.name, group);
|
|
20
|
+
}
|
|
21
|
+
return [...groups.values()];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* describe(row) returns a process descriptor with a name, command and optional
|
|
26
|
+
* env and resources. All sources describing the same process must agree.
|
|
27
|
+
* project(descriptor, rows) supplies adapter-specific env and optional hostDevices.
|
|
28
|
+
* A physical device may appear in many source rows but belongs to only one process.
|
|
29
|
+
*/
|
|
30
|
+
export default function collectorProcesses(machines, describe, project) {
|
|
31
|
+
const devices = new Set();
|
|
32
|
+
return grouped(machines, describe).map(({ descriptor, rows }) => {
|
|
33
|
+
const settings = project(descriptor, rows);
|
|
34
|
+
const hostDevices = [...new Set(settings.hostDevices || [])].sort();
|
|
35
|
+
hostDevices.forEach((device) => {
|
|
36
|
+
if (devices.has(device)) { throw new Error(`serial port or device ${device} is shared between processes`); }
|
|
37
|
+
devices.add(device);
|
|
38
|
+
});
|
|
39
|
+
return { name: descriptor.name, command: descriptor.command, env: { ...descriptor.env, ...settings.env },
|
|
40
|
+
hostDevices, resources: descriptor.resources };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { assertLeaf, deviceFlows, expandObjects } from './expandDevices.js';
|
|
2
|
+
import { availableNames, expandApis, publishedNames } from './publishedStreams.js';
|
|
3
|
+
import { serviceOf } from './releaseNodes.js';
|
|
2
4
|
import { isAddress, picked } from './expandPattern.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -32,18 +34,17 @@ function chosen(pattern, pool, strict) {
|
|
|
32
34
|
* @example
|
|
33
35
|
* poolOf({ fanIn: [], fanOut: [] }, 'archive');
|
|
34
36
|
*/
|
|
35
|
-
function poolOf(source, receiverId) {
|
|
37
|
+
function poolOf(source, receiverId, api) {
|
|
38
|
+
const available = availableNames(source, api);
|
|
36
39
|
const aimed = source.fanOut.filter((flow) => {
|
|
37
40
|
return flow.to === receiverId;
|
|
38
41
|
}).map((flow) => {
|
|
39
42
|
return flow.name;
|
|
40
43
|
});
|
|
41
44
|
if (aimed.length > 0) {
|
|
42
|
-
return aimed;
|
|
45
|
+
return aimed.filter((name) => { return available.includes(name); });
|
|
43
46
|
}
|
|
44
|
-
return
|
|
45
|
-
return flow.name;
|
|
46
|
-
});
|
|
47
|
+
return available;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -60,9 +61,7 @@ function poolOf(source, receiverId) {
|
|
|
60
61
|
*/
|
|
61
62
|
function expandOut(server, ids, strict) {
|
|
62
63
|
const rows = [];
|
|
63
|
-
const streams = server
|
|
64
|
-
return flow.name;
|
|
65
|
-
});
|
|
64
|
+
const streams = availableNames(server);
|
|
66
65
|
server.patternsOut.forEach((flow) => {
|
|
67
66
|
const destinations = picked(flow.to, ids, 'server', true);
|
|
68
67
|
const names = picked(flow.name, streams, 'path', strict);
|
|
@@ -76,6 +75,18 @@ function expandOut(server, ids, strict) {
|
|
|
76
75
|
return rows;
|
|
77
76
|
}
|
|
78
77
|
|
|
78
|
+
/* A fixed object path can be held implicitly, but an API-only stream requires its service. */
|
|
79
|
+
function incomingNames(source, receiver, flow, strict) {
|
|
80
|
+
const api = serviceOf(source, flow.api)?.id;
|
|
81
|
+
const pool = poolOf(source, receiver, api);
|
|
82
|
+
const names = chosen(flow.name, pool, strict);
|
|
83
|
+
if (strict && api && publishedNames(source).includes(flow.name)
|
|
84
|
+
&& !availableNames(source, api).includes(flow.name)) {
|
|
85
|
+
throw new Error(`system document stream ${flow.name} is unavailable on api ${flow.api}`);
|
|
86
|
+
}
|
|
87
|
+
return names;
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/**
|
|
80
91
|
* Expands fan-in whose source is a server.
|
|
81
92
|
*
|
|
@@ -101,7 +112,7 @@ function expandIn(server, drafts, strict) {
|
|
|
101
112
|
const source = drafts.find((item) => {
|
|
102
113
|
return item.id === sourceId;
|
|
103
114
|
});
|
|
104
|
-
|
|
115
|
+
incomingNames(source, server.id, flow, strict).forEach((path) => {
|
|
105
116
|
assertLeaf(server.objects, path);
|
|
106
117
|
const copy = { from: sourceId, name: path };
|
|
107
118
|
if (flow.api) {
|
|
@@ -144,7 +155,7 @@ function draftOne(server, paths, mappings) {
|
|
|
144
155
|
remote.push(flow);
|
|
145
156
|
});
|
|
146
157
|
return {
|
|
147
|
-
api: server.api,
|
|
158
|
+
api: expandApis(server.api, paths, objects),
|
|
148
159
|
fanIn: local,
|
|
149
160
|
id: server.id,
|
|
150
161
|
objects,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { assertLeaf } from './expandDevices.js';
|
|
2
|
+
import { picked } from './expandPattern.js';
|
|
3
|
+
|
|
4
|
+
/** Resolve each API's published stream names, preserving its opaque contract metadata. */
|
|
5
|
+
export function expandApis(apis, paths, objects) {
|
|
6
|
+
return apis?.map((api) => {
|
|
7
|
+
if (!api.publishes) { return api; }
|
|
8
|
+
const publishes = api.publishes.flatMap((entry) => {
|
|
9
|
+
return picked(entry.name, paths, 'path', true).map((name) => {
|
|
10
|
+
assertLeaf(objects, name);
|
|
11
|
+
return { ...entry, name };
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
return { ...api, publishes };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Streams made available by publishers on the selected service. */
|
|
19
|
+
export function publishedNames(server, api) {
|
|
20
|
+
return (server.api || []).filter((item) => {
|
|
21
|
+
return !api || item.id === api;
|
|
22
|
+
}).flatMap((item) => {
|
|
23
|
+
return (item.publishes || []).map(({ name }) => { return name; });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Locally collected and forwarded streams stay available through the server's services. */
|
|
28
|
+
export function availableNames(source, api) {
|
|
29
|
+
return [...new Set([...source.fanIn.map(({ name }) => { return name; }), ...publishedNames(source, api)])];
|
|
30
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publishedNames } from './publishedStreams.js';
|
|
1
2
|
import { isAddress } from './expandPattern.js';
|
|
2
3
|
import { asPath, objectPath, serverById } from './releaseNodes.js';
|
|
3
4
|
|
|
@@ -28,28 +29,22 @@ function sameObjects(left, right) {
|
|
|
28
29
|
* remember([], { from: 'cell', object: 'unit', side: 'out', to: 'archive' });
|
|
29
30
|
*/
|
|
30
31
|
function remember(edges, edge) {
|
|
31
|
-
|
|
32
|
+
let found = edges.find((item) => {
|
|
32
33
|
return item.from === edge.from && item.to === edge.to;
|
|
33
34
|
});
|
|
34
35
|
if (!found) {
|
|
35
|
-
|
|
36
|
-
|
|
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);
|
|
36
|
+
found = { from: edge.from, to: edge.to, sides: {}, streams: new Map() };
|
|
37
|
+
edges.push(found);
|
|
47
38
|
}
|
|
48
|
-
found.sides[edge.side]
|
|
49
|
-
|
|
39
|
+
found.sides[edge.side] ||= [];
|
|
40
|
+
const objects = found.sides[edge.side];
|
|
41
|
+
if (!objects.includes(edge.object)) { objects.push(edge.object); }
|
|
42
|
+
const services = found.streams.get(edge.name) || new Set();
|
|
50
43
|
if (edge.api) {
|
|
44
|
+
services.add(edge.api);
|
|
51
45
|
found.api = edge.api;
|
|
52
46
|
}
|
|
47
|
+
found.streams.set(edge.name, services);
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
/**
|
|
@@ -67,6 +62,7 @@ export function edgesOf(document) {
|
|
|
67
62
|
const dest = serverById(document, flow.to);
|
|
68
63
|
remember(edges, {
|
|
69
64
|
from: server.id,
|
|
65
|
+
name: flow.name,
|
|
70
66
|
object: objectPath(server, flow.name),
|
|
71
67
|
side: 'out',
|
|
72
68
|
to: dest.id
|
|
@@ -80,6 +76,7 @@ export function edgesOf(document) {
|
|
|
80
76
|
remember(edges, {
|
|
81
77
|
api: flow.api,
|
|
82
78
|
from: source.id,
|
|
79
|
+
name: flow.name,
|
|
83
80
|
object: objectPath(source, flow.name),
|
|
84
81
|
side: 'in',
|
|
85
82
|
to: server.id
|
|
@@ -119,7 +116,7 @@ function covers(held, objectId) {
|
|
|
119
116
|
* direct({ fan_in: [], id: 'cell' });
|
|
120
117
|
*/
|
|
121
118
|
function direct(server) {
|
|
122
|
-
const names =
|
|
119
|
+
const names = publishedNames(server);
|
|
123
120
|
(server.fan_in || []).forEach((flow) => {
|
|
124
121
|
if (isAddress(flow.from)) {
|
|
125
122
|
names.push(flow.name);
|
|
@@ -116,3 +116,16 @@ export function profileFor(document, server) {
|
|
|
116
116
|
const values = { ...(profile.variables || {}), ...(server.profile.variables || {}) };
|
|
117
117
|
return { ...profile, variables: values };
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Selects an explicitly named service or the sole service of the source. */
|
|
121
|
+
export function serviceOf(source, api) {
|
|
122
|
+
const listed = source.api || [];
|
|
123
|
+
if (!api && listed.length > 1) {
|
|
124
|
+
throw new Error(`system document server ${source.id} must name one api`);
|
|
125
|
+
}
|
|
126
|
+
const service = api ? listed.find((item) => { return item.id === api; }) : listed[0];
|
|
127
|
+
if (api && !service) {
|
|
128
|
+
throw new Error(`system document server ${source.id} provides no api ${api}`);
|
|
129
|
+
}
|
|
130
|
+
return service;
|
|
131
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAddress } from './expandPattern.js';
|
|
2
|
-
import { clusterOf, objectPath, serverById } from './releaseNodes.js';
|
|
2
|
+
import { clusterOf, objectPath, serverById, serviceOf } from './releaseNodes.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Maps one module name onto a chart product.
|
|
@@ -16,7 +16,7 @@ function bindOne(name, spec, secret) {
|
|
|
16
16
|
return ['postgres', { enabled: spec.enabled !== false, secret: secret || 'scada-postgres' }];
|
|
17
17
|
}
|
|
18
18
|
if (name === 'message_bus') {
|
|
19
|
-
return ['rabbitmq', { enabled: spec.enabled !== false,
|
|
19
|
+
return ['rabbitmq', { ...spec.chart, enabled: spec.enabled !== false,
|
|
20
20
|
...(spec.dead_letter ? { deadLetter: spec.dead_letter } : {}) }];
|
|
21
21
|
}
|
|
22
22
|
if (name === 'telemetry_store') {
|
|
@@ -103,19 +103,8 @@ export function machinesOf(server) {
|
|
|
103
103
|
* plantUrl({ api: [{ id: 'plant', url: 'http://192.0.2.10/api/v1' }], id: 'cell' });
|
|
104
104
|
*/
|
|
105
105
|
function plantUrl(source, api) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
return item.id === api;
|
|
109
|
-
})) {
|
|
110
|
-
throw new Error(`system document server ${source.id} provides no api ${api}`);
|
|
111
|
-
}
|
|
112
|
-
if (!api && listed.length > 1) {
|
|
113
|
-
throw new Error(`system document server ${source.id} must name one api`);
|
|
114
|
-
}
|
|
115
|
-
const plant = listed.find((item) => {
|
|
116
|
-
return item.id === 'plant';
|
|
117
|
-
});
|
|
118
|
-
return plant ? plant.url : '';
|
|
106
|
+
const service = serviceOf(source, api);
|
|
107
|
+
return service?.id === 'plant' ? service.url : '';
|
|
119
108
|
}
|
|
120
109
|
|
|
121
110
|
/**
|
|
@@ -18,7 +18,8 @@ function withImage(profile) {
|
|
|
18
18
|
if (!image) {
|
|
19
19
|
throw new Error('system document has no scada image');
|
|
20
20
|
}
|
|
21
|
-
return { image, ...
|
|
21
|
+
return { image, ...(app.site && { site: app.site }), ...(app.sink && { sink: app.sink }),
|
|
22
|
+
...bindModules(profile, app.secret) };
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -109,6 +110,7 @@ export default function releases(document, cluster) {
|
|
|
109
110
|
const profile = profileFor(expanded, host);
|
|
110
111
|
assertHeld(expanded, links);
|
|
111
112
|
return frozen({
|
|
113
|
+
...(profile.target && { target: profile.target }),
|
|
112
114
|
apps: passedApps(profile),
|
|
113
115
|
scada: scadaRelease(expanded, host, profile, links)
|
|
114
116
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { assertHeld, edgesOf } from './releaseEdges.js';
|
|
2
|
+
import { serverById } from './releaseNodes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lists directed stream subscriptions for a source server in an expanded document.
|
|
6
|
+
* A receiver may explicitly select a service. A sender-only declaration has none.
|
|
7
|
+
*/
|
|
8
|
+
export default function subscriptions(document, sourceId) {
|
|
9
|
+
serverById(document, sourceId);
|
|
10
|
+
const edges = edgesOf(document);
|
|
11
|
+
assertHeld(document, edges);
|
|
12
|
+
const rows = [];
|
|
13
|
+
for (const { from, to, streams } of edges.filter((edge) => { return edge.from === sourceId; })) {
|
|
14
|
+
for (const [name, services] of streams) {
|
|
15
|
+
if (!services.size) { rows.push({ from, to, name }); }
|
|
16
|
+
services.forEach((api) => { rows.push({ from, to, name, api }); });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Validates the target before helm or kubectl can fall back to a default. */
|
|
4
|
+
function validate(plan, action) {
|
|
5
|
+
if (!['render', 'check', 'diff', 'apply', 'adopt', 'check-adoption'].includes(action)) {
|
|
6
|
+
throw new Error('action must be render, check, diff or apply (adopt/check-adoption for migration)');
|
|
7
|
+
}
|
|
8
|
+
const fields = action === 'render' ? ['chart', 'release', 'namespace'] : ['chart', 'release', 'namespace', 'context', 'fieldManager'];
|
|
9
|
+
fields.forEach((key) => {
|
|
10
|
+
if (typeof plan[key] !== 'string' || !plan[key].trim()) { throw new Error(`release requires ${key}`); }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Every Kubernetes command, including the post-rollout hook, shares this target. */
|
|
15
|
+
function kubeAt(plan, execute) {
|
|
16
|
+
return (args, input) => {
|
|
17
|
+
return execute('kubectl', ['--context', plan.context, '--namespace', plan.namespace, ...args],
|
|
18
|
+
{ input, encoding: 'utf8', stdio: ['pipe', 'inherit', 'inherit'] });
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyWith(kube, manifests, flags, checkOnly) {
|
|
23
|
+
const args = ['apply', ...flags, '-f', '-'];
|
|
24
|
+
kube([...args, '--dry-run=server'], manifests);
|
|
25
|
+
if (!checkOnly) { kube(args, manifests); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Adopt legacy fields, then enforce the complete manifest before waiting for rollouts. */
|
|
29
|
+
function applyRelease(plan, action, kube, manifests) {
|
|
30
|
+
if (['adopt', 'check-adoption'].includes(action)) {
|
|
31
|
+
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], action === 'check-adoption');
|
|
32
|
+
if (action === 'check-adoption') { return ''; }
|
|
33
|
+
}
|
|
34
|
+
applyWith(kube, manifests, ['--server-side', `--field-manager=${plan.fieldManager}`], action === 'check');
|
|
35
|
+
if (action === 'check') { return ''; }
|
|
36
|
+
for (const workload of plan.workloads || []) {
|
|
37
|
+
kube(['rollout', 'status', workload, '--timeout=300s']);
|
|
38
|
+
}
|
|
39
|
+
plan.afterReady?.(plan, kube);
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Renders a Helm release and executes the selected kubectl action synchronously.
|
|
45
|
+
* Supply chart, release, namespace and values. Cluster actions additionally require
|
|
46
|
+
* an explicit context and fieldManager. prerequisites and workloads are kubectl
|
|
47
|
+
* resource/name references. afterReady(plan, kube) runs after a successful apply.
|
|
48
|
+
*/
|
|
49
|
+
export default function executeRelease(plan, action = 'render', execute = execFileSync) {
|
|
50
|
+
validate(plan, action);
|
|
51
|
+
const manifests = execute('helm', ['template', plan.release, plan.chart, '--namespace', plan.namespace, '-f', '-'],
|
|
52
|
+
{ input: JSON.stringify(plan.values), encoding: 'utf8' });
|
|
53
|
+
if (action === 'render') { return manifests; }
|
|
54
|
+
const kube = kubeAt(plan, execute);
|
|
55
|
+
for (const resource of plan.prerequisites || []) { kube(['get', resource, '-o', 'name']); }
|
|
56
|
+
if (action === 'diff') {
|
|
57
|
+
return kube(['diff', '--server-side', `--field-manager=${plan.fieldManager}`, '-f', '-'], manifests);
|
|
58
|
+
}
|
|
59
|
+
return applyRelease(plan, action, kube, manifests);
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|