@yarkivaev/scada 2.4.8 → 2.5.0
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 +60 -3
- package/charts/scada/templates/_helpers.tpl +9 -0
- package/charts/scada/templates/extra-objects.yaml +4 -0
- package/charts/scada/templates/postgres.yaml +52 -15
- package/charts/scada/templates/processes.yaml +37 -4
- package/charts/scada/templates/rabbitmq.yaml +22 -10
- package/charts/scada/templates/site-server.yaml +12 -0
- package/charts/scada/values.schema.json +111 -14
- package/charts/scada/values.yaml +18 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/application/deploy/expandFlows.js +4 -0
- package/src/application/deploy/mergeDocuments.js +1 -1
- package/src/application/deploy/releaseNodes.js +3 -1
- package/src/application/deploy/releaseValues.js +2 -1
- package/src/application/runtime/additionalAdapters.js +17 -1
- package/src/application/runtime/runtimeHealth.js +3 -1
- package/src/infrastructure/deploy/executeRelease.js +22 -0
- package/src/infrastructure/deploy/executeStages.js +18 -0
- package/src/infrastructure/ingest/db/migrate.js +9 -7
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +29 -1
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +9 -2
- package/src/infrastructure/streams/mqttSource.js +16 -1
package/README.md
CHANGED
|
@@ -420,7 +420,7 @@ await runtime.stop();
|
|
|
420
420
|
|
|
421
421
|
Adapters include `stream` (registered source/transform/sinks), `modbus`,
|
|
422
422
|
`activity`, `amqp-relay`, `amqp-metrics`, `mqtt-metrics`, `operation-sync`,
|
|
423
|
-
`amqp-pg-metrics`, `silent-streams` and `stomp-ingest`. Plant packages extend the
|
|
423
|
+
`amqp-pg-metrics`, `mqtt-pg-metrics`, `silent-streams` and `stomp-ingest`. Plant packages extend the
|
|
424
424
|
registry with domain-specific adapters; OPC UA belongs here, not in
|
|
425
425
|
source-to-sink. Constructors do not open connections. A task owns independent
|
|
426
426
|
connections and must close them in `stop()`. Factories are trusted code, not
|
|
@@ -439,8 +439,11 @@ Secrets can be references such as `{ "$secret": "AMQP_URL" }`, resolved by the
|
|
|
439
439
|
host immediately before adapter construction. Status excludes configuration and
|
|
440
440
|
exception messages. Optional `COLLECTOR_HEALTH_PORT` and
|
|
441
441
|
`COLLECTOR_HEALTH_HOST` (default `127.0.0.1`) expose read-only `/live`, `/ready`
|
|
442
|
-
and `/status`. Readiness
|
|
443
|
-
|
|
442
|
+
and `/status`. Readiness rejects failed or degraded tasks as well as runtime
|
|
443
|
+
shutdown. Fatal cleanup or an uncertain MQTT acceptance/write timeout makes
|
|
444
|
+
liveness fail; configure an external restart policy. Transient MQTT errors mark
|
|
445
|
+
the task degraded until broker reconnect; terminal timeouts require a fresh
|
|
446
|
+
executor after the old process drains its IO. A blocked event loop cannot answer
|
|
444
447
|
an external probe; synchronous infinite loops/native crashes still affect the
|
|
445
448
|
entire process. No hard CPU or native-memory isolation is claimed.
|
|
446
449
|
|
|
@@ -464,6 +467,23 @@ deduplicate. Sources with a non-null checkpoint must implement `restore()`;
|
|
|
464
467
|
unsupported restoration fails closed. Live telemetry cannot replay downtime.
|
|
465
468
|
Loki fails a saturated page rather than silently advancing across truncated data.
|
|
466
469
|
|
|
470
|
+
`mqtt-pg-metrics` uses `{ mqtt, postgres, topic, options, limits }`. URLs may be
|
|
471
|
+
secret references; `options.clientId` and `options.sessionExpiryInterval` preserve
|
|
472
|
+
an existing persistent MQTT session. Records use the existing numeric/JSON metric
|
|
473
|
+
decoder and are acknowledged after PostgreSQL writes. Runtime limits bound accepted
|
|
474
|
+
records and bytes; a one-record budget works without reserving the record twice.
|
|
475
|
+
|
|
476
|
+
The `modbus` adapter also accepts `config.plans`, one non-empty `[{ point, topic }]`
|
|
477
|
+
array per device. For example, `plans: [[{ point: 'holding:4000:cdab',
|
|
478
|
+
topic: 'existing/device/voltage' }]]` publishes that exact wire topic. Points must
|
|
479
|
+
use `holding:<integer>[:abcd|cdab]` within the 16-bit register address range.
|
|
480
|
+
Explicit plans replace `maps`; mixing the two or repeating a topic is rejected.
|
|
481
|
+
All devices still share one poll window.
|
|
482
|
+
|
|
483
|
+
Collector state is optional for live-only assignments. Ordinary supervisor-sink
|
|
484
|
+
business migrations skip collector state and never delete an existing state table.
|
|
485
|
+
Call `migrate(pool, migrationsDir, profile, { runtimeState: true })` only when
|
|
486
|
+
explicitly enabling task-state storage, or apply its SQL separately.
|
|
467
487
|
The runner accepts `COLLECTOR_STATE_URL` for PostgreSQL. Apply
|
|
468
488
|
`db/migrations/V0009__collector_task_state.sql` explicitly and grant the service
|
|
469
489
|
role access before starting stateful assignments; the runner never runs schema
|
|
@@ -522,3 +542,40 @@ for idempotent replay. WAL durability remains enabled.
|
|
|
522
542
|
counts, SQL-call count, and maximum transaction/wait durations. They contain no payloads or tags.
|
|
523
543
|
Compare these counters with RabbitMQ ACK/drain rates and process memory when tuning. More messages
|
|
524
544
|
per batch reduce COMMIT frequency; the number of SQL writes still depends on command semantics.
|
|
545
|
+
|
|
546
|
+
## Ordered release stages
|
|
547
|
+
|
|
548
|
+
`executeStages(stages, action = 'render', execute?)` runs ordered generic release
|
|
549
|
+
plans. For `apply`/`adopt`, every stage is preflighted before the first mutation;
|
|
550
|
+
each rollout and `afterReady`/`beforeApply` gate must finish before the next stage.
|
|
551
|
+
Read-only actions do not invoke mutation gates. A plan may declare exact Deployment
|
|
552
|
+
`retirements: [{ workload: 'deployment/old-reader', selector: 'app=old-reader' }]`
|
|
553
|
+
and `podsAbsentBeforeApply` selectors to fence old readers before a replacement.
|
|
554
|
+
Retirement waits for foreground deletion and verifies no matching pods remain;
|
|
555
|
+
failures stop execution. Context, namespace and field manager remain explicit.
|
|
556
|
+
|
|
557
|
+
The application chart accepts existing PostgreSQL/RabbitMQ claims, secret-backed
|
|
558
|
+
environment values, `envFrom`, config volumes, probes and extra Kubernetes objects.
|
|
559
|
+
PostgreSQL honors the root `pullSecret`. Existing claims are referenced without
|
|
560
|
+
creating or deleting storage; plant packages supply their precise deployment values.
|
|
561
|
+
|
|
562
|
+
### Per-server module configuration
|
|
563
|
+
|
|
564
|
+
A server may override its profile's `modules`. Maps merge recursively; omitted
|
|
565
|
+
fields are inherited, arrays replace, and null removes an inherited key.
|
|
566
|
+
`state_store.pgdata` selects PostgreSQL's existing data directory and takes
|
|
567
|
+
precedence over the compatible `state_store.chart.data` setting.
|
|
568
|
+
|
|
569
|
+
```yaml
|
|
570
|
+
servers:
|
|
571
|
+
- id: cell-a
|
|
572
|
+
modules:
|
|
573
|
+
state_store:
|
|
574
|
+
pgdata: /var/lib/postgresql/data/pgdata
|
|
575
|
+
profile:
|
|
576
|
+
id: cell
|
|
577
|
+
variables:
|
|
578
|
+
cluster: cell-a
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
This changes the selected server only; it does not mutate the shared profile.
|
|
@@ -94,3 +94,12 @@ volumeMode: Filesystem
|
|
|
94
94
|
{{- define "scada.brokerConfigName" -}}
|
|
95
95
|
{{- default (printf "%s-rabbitmq" (include "scada.prefix" .)) .Values.rabbitmq.configName -}}
|
|
96
96
|
{{- end }}
|
|
97
|
+
|
|
98
|
+
{{/* Literal env values remain strings; Kubernetes valueFrom references stay opaque. */}}
|
|
99
|
+
{{- define "scada.envValue" -}}
|
|
100
|
+
{{- if kindIs "map" . }}
|
|
101
|
+
{{- toYaml . }}
|
|
102
|
+
{{- else }}
|
|
103
|
+
value: {{ . | quote }}
|
|
104
|
+
{{- end }}
|
|
105
|
+
{{- end }}
|
|
@@ -11,7 +11,7 @@ spec:
|
|
|
11
11
|
ports:
|
|
12
12
|
- name: postgres
|
|
13
13
|
port: 5432
|
|
14
|
-
targetPort:
|
|
14
|
+
targetPort: 5432
|
|
15
15
|
selector:
|
|
16
16
|
{{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 4 }}
|
|
17
17
|
---
|
|
@@ -30,38 +30,75 @@ spec:
|
|
|
30
30
|
matchLabels:
|
|
31
31
|
{{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 6 }}
|
|
32
32
|
template:
|
|
33
|
+
{{- if .Values.postgres.podTemplate }}
|
|
34
|
+
{{- toYaml .Values.postgres.podTemplate | nindent 4 }}
|
|
35
|
+
{{- else }}
|
|
33
36
|
metadata:
|
|
34
37
|
labels:
|
|
35
38
|
{{- include "scada.labels" . | nindent 8 }}
|
|
36
39
|
{{- include "scada.match" (dict "app" "postgres" "ctx" .) | nindent 8 }}
|
|
37
40
|
spec:
|
|
41
|
+
{{- with .Values.pullSecret }}
|
|
42
|
+
imagePullSecrets:
|
|
43
|
+
- name: {{ . }}
|
|
44
|
+
{{- end }}
|
|
38
45
|
containers:
|
|
39
46
|
- name: postgres
|
|
40
47
|
image: {{ .Values.postgres.image | quote }}
|
|
48
|
+
{{- with .Values.postgres.command }}
|
|
49
|
+
command: {{ toJson . }}
|
|
50
|
+
{{- end }}
|
|
51
|
+
{{- with .Values.postgres.args }}
|
|
52
|
+
args: {{ toJson . }}
|
|
53
|
+
{{- end }}
|
|
41
54
|
ports:
|
|
42
55
|
- name: postgres
|
|
43
56
|
containerPort: 5432
|
|
44
57
|
env:
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
{{- $env := dict "POSTGRES_DB" .Values.postgres.database "PGDATA" .Values.postgres.data
|
|
59
|
+
"POSTGRES_USER" (dict "valueFrom" (dict "secretKeyRef" (dict "name" .Values.postgres.secret "key" "user")))
|
|
60
|
+
"POSTGRES_PASSWORD" (dict "valueFrom" (dict "secretKeyRef" (dict "name" .Values.postgres.secret "key" "password"))) }}
|
|
61
|
+
{{- range $key, $value := .Values.postgres.env }}
|
|
62
|
+
{{- $_ := set $env $key $value }}
|
|
63
|
+
{{- end }}
|
|
64
|
+
{{- $order := concat (list "POSTGRES_DB" "POSTGRES_USER" "POSTGRES_PASSWORD" "PGDATA") (without (keys $env | sortAlpha) "POSTGRES_DB" "POSTGRES_USER" "POSTGRES_PASSWORD" "PGDATA") }}
|
|
65
|
+
{{- range $key := $order }}
|
|
66
|
+
- name: {{ $key }}
|
|
67
|
+
{{- include "scada.envValue" (index $env $key) | nindent 14 }}
|
|
68
|
+
{{- end }}
|
|
69
|
+
{{- with .Values.postgres.envFrom }}
|
|
70
|
+
envFrom:
|
|
71
|
+
{{- toYaml . | nindent 12 }}
|
|
72
|
+
{{- end }}
|
|
73
|
+
{{- range $key := list "readinessProbe" "livenessProbe" "startupProbe" "resources" }}
|
|
74
|
+
{{- with index $.Values.postgres $key }}
|
|
75
|
+
{{ $key }}:
|
|
76
|
+
{{- toYaml . | nindent 12 }}
|
|
77
|
+
{{- end }}
|
|
78
|
+
{{- end }}
|
|
59
79
|
volumeMounts:
|
|
60
80
|
- name: data
|
|
61
81
|
mountPath: /var/lib/postgresql/data
|
|
82
|
+
{{- with .Values.postgres.volumeMounts }}
|
|
83
|
+
{{- toYaml . | nindent 12 }}
|
|
84
|
+
{{- end }}
|
|
85
|
+
{{- if or .Values.postgres.existingClaim .Values.postgres.volumes }}
|
|
86
|
+
volumes:
|
|
87
|
+
{{- if .Values.postgres.existingClaim }}
|
|
88
|
+
- name: data
|
|
89
|
+
persistentVolumeClaim:
|
|
90
|
+
claimName: {{ .Values.postgres.existingClaim }}
|
|
91
|
+
{{- end }}
|
|
92
|
+
{{- with .Values.postgres.volumes }}
|
|
93
|
+
{{- toYaml . | nindent 8 }}
|
|
94
|
+
{{- end }}
|
|
95
|
+
{{- end }}
|
|
96
|
+
{{- end }}
|
|
97
|
+
{{- if not .Values.postgres.existingClaim }}
|
|
62
98
|
volumeClaimTemplates:
|
|
63
99
|
- metadata:
|
|
64
100
|
name: data
|
|
65
101
|
spec:
|
|
66
102
|
{{- include "scada.claim" (dict "size" .Values.postgres.storage "class" .Values.postgres.storageClass) | nindent 8 }}
|
|
67
103
|
{{- end }}
|
|
104
|
+
{{- end }}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{{- $names := dict }}
|
|
2
2
|
{{- $devices := dict }}
|
|
3
3
|
{{- range .Values.processes }}
|
|
4
|
+
{{- $process := . }}
|
|
4
5
|
{{- $name := required "process name is required" .name }}
|
|
5
6
|
{{- if hasKey $names $name }}
|
|
6
7
|
{{- fail (printf "duplicate process name %s" $name) }}
|
|
@@ -58,11 +59,22 @@ spec:
|
|
|
58
59
|
image: {{ required "process image is required" (default $.Values.image .image) | quote }}
|
|
59
60
|
imagePullPolicy: IfNotPresent
|
|
60
61
|
command: {{ required "process command is required" .command | toJson }}
|
|
61
|
-
{{- with .
|
|
62
|
+
{{- with .args }}
|
|
63
|
+
args: {{ toJson . }}
|
|
64
|
+
{{- end }}
|
|
65
|
+
{{- if or .ports .health }}
|
|
62
66
|
ports:
|
|
67
|
+
{{- with .ports }}
|
|
68
|
+
{{- toYaml . | nindent 12 }}
|
|
69
|
+
{{- end }}
|
|
70
|
+
{{- with .health }}
|
|
63
71
|
- name: health
|
|
64
72
|
containerPort: {{ .port }}
|
|
65
73
|
protocol: TCP
|
|
74
|
+
{{- end }}
|
|
75
|
+
{{- end }}
|
|
76
|
+
{{- with .health }}
|
|
77
|
+
{{- if not $process.livenessProbe }}
|
|
66
78
|
livenessProbe:
|
|
67
79
|
httpGet:
|
|
68
80
|
path: {{ default "/live" .livenessPath | quote }}
|
|
@@ -71,6 +83,8 @@ spec:
|
|
|
71
83
|
periodSeconds: {{ default 10 .periodSeconds }}
|
|
72
84
|
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
73
85
|
failureThreshold: {{ default 3 .failureThreshold }}
|
|
86
|
+
{{- end }}
|
|
87
|
+
{{- if not $process.readinessProbe }}
|
|
74
88
|
readinessProbe:
|
|
75
89
|
httpGet:
|
|
76
90
|
path: {{ default "/ready" .readinessPath | quote }}
|
|
@@ -80,31 +94,50 @@ spec:
|
|
|
80
94
|
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
81
95
|
failureThreshold: {{ default 3 .failureThreshold }}
|
|
82
96
|
{{- end }}
|
|
97
|
+
{{- end }}
|
|
98
|
+
{{- range $key := list "readinessProbe" "livenessProbe" "startupProbe" }}
|
|
99
|
+
{{- with index $process $key }}
|
|
100
|
+
{{ $key }}:
|
|
101
|
+
{{- toYaml . | nindent 12 }}
|
|
102
|
+
{{- end }}
|
|
103
|
+
{{- end }}
|
|
83
104
|
{{- if .hostDevices }}
|
|
84
105
|
securityContext:
|
|
85
106
|
privileged: true
|
|
107
|
+
{{- end }}
|
|
108
|
+
{{- if or .hostDevices .volumeMounts }}
|
|
86
109
|
volumeMounts:
|
|
87
110
|
{{- range $index, $path := .hostDevices }}
|
|
88
111
|
- name: serial-{{ add1 $index }}
|
|
89
112
|
mountPath: {{ $path | quote }}
|
|
90
113
|
{{- end }}
|
|
114
|
+
{{- with .volumeMounts }}
|
|
115
|
+
{{- toYaml . | nindent 12 }}
|
|
116
|
+
{{- end }}
|
|
91
117
|
{{- end }}
|
|
92
118
|
{{- with .env }}
|
|
93
119
|
env:
|
|
94
120
|
{{- range $key, $value := . }}
|
|
95
121
|
- name: {{ $key }}
|
|
96
|
-
|
|
122
|
+
{{- include "scada.envValue" $value | nindent 14 }}
|
|
97
123
|
{{- end }}
|
|
98
124
|
{{- end }}
|
|
125
|
+
{{- with .envFrom }}
|
|
126
|
+
envFrom:
|
|
127
|
+
{{- toYaml . | nindent 12 }}
|
|
128
|
+
{{- end }}
|
|
99
129
|
resources:
|
|
100
130
|
{{- toYaml (default $.Values.resources .resources) | nindent 12 }}
|
|
101
|
-
{{-
|
|
131
|
+
{{- if or .hostDevices .volumes }}
|
|
102
132
|
volumes:
|
|
103
|
-
{{- range $index, $path := . }}
|
|
133
|
+
{{- range $index, $path := .hostDevices }}
|
|
104
134
|
- name: serial-{{ add1 $index }}
|
|
105
135
|
hostPath:
|
|
106
136
|
path: {{ $path | quote }}
|
|
107
137
|
type: CharDevice
|
|
108
138
|
{{- end }}
|
|
139
|
+
{{- with .volumes }}
|
|
140
|
+
{{- toYaml . | nindent 8 }}
|
|
141
|
+
{{- end }}
|
|
109
142
|
{{- end }}
|
|
110
143
|
{{- end }}
|
|
@@ -40,6 +40,9 @@ spec:
|
|
|
40
40
|
matchLabels:
|
|
41
41
|
{{- include "scada.match" (dict "app" "rabbitmq" "ctx" .) | nindent 6 }}
|
|
42
42
|
template:
|
|
43
|
+
{{- if .Values.rabbitmq.podTemplate }}
|
|
44
|
+
{{- toYaml .Values.rabbitmq.podTemplate | nindent 4 }}
|
|
45
|
+
{{- else }}
|
|
43
46
|
metadata:
|
|
44
47
|
labels:
|
|
45
48
|
{{- include "scada.labels" . | nindent 8 }}
|
|
@@ -47,9 +50,14 @@ spec:
|
|
|
47
50
|
{{- with .Values.workloadLabels }}
|
|
48
51
|
{{- toYaml . | nindent 8 }}
|
|
49
52
|
{{- end }}
|
|
50
|
-
{{- if .Values.rabbitmq.rolloutOnConfigChange }}
|
|
53
|
+
{{- if or .Values.rabbitmq.rolloutOnConfigChange .Values.rabbitmq.podAnnotations }}
|
|
51
54
|
annotations:
|
|
55
|
+
{{- with .Values.rabbitmq.podAnnotations }}
|
|
56
|
+
{{- toYaml . | nindent 8 }}
|
|
57
|
+
{{- end }}
|
|
58
|
+
{{- if .Values.rabbitmq.rolloutOnConfigChange }}
|
|
52
59
|
checksum/config: {{ include (print $.Template.BasePath "/rabbitmq-config.yaml") . | sha256sum }}
|
|
60
|
+
{{- end }}
|
|
53
61
|
{{- end }}
|
|
54
62
|
spec:
|
|
55
63
|
{{- with .Values.pullSecret }}
|
|
@@ -103,19 +111,23 @@ spec:
|
|
|
103
111
|
- name: data
|
|
104
112
|
persistentVolumeClaim:
|
|
105
113
|
claimName: {{ . }}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 }}
|
|
114
|
+
{{- end }}
|
|
115
|
+
{{- end }}
|
|
116
|
+
{{- if not .Values.rabbitmq.claimName }}
|
|
115
117
|
volumeClaimTemplates:
|
|
116
118
|
- metadata:
|
|
117
119
|
name: data
|
|
118
120
|
spec:
|
|
119
121
|
{{- include "scada.claim" (dict "size" .Values.rabbitmq.storage "class" .Values.rabbitmq.storageClass) | nindent 8 }}
|
|
122
|
+
{{- end }}
|
|
123
|
+
{{- if and .Values.rabbitmq.claimName .Values.rabbitmq.createClaim }}
|
|
124
|
+
---
|
|
125
|
+
apiVersion: v1
|
|
126
|
+
kind: PersistentVolumeClaim
|
|
127
|
+
metadata:
|
|
128
|
+
name: {{ .Values.rabbitmq.claimName }}
|
|
129
|
+
namespace: {{ .Release.Namespace }}
|
|
130
|
+
spec:
|
|
131
|
+
{{- include "scada.claim" (dict "size" .Values.rabbitmq.storage "class" .Values.rabbitmq.storageClass) | nindent 2 }}
|
|
120
132
|
{{- end }}
|
|
121
133
|
{{- end }}
|
|
@@ -9,6 +9,9 @@ metadata:
|
|
|
9
9
|
app: site-server
|
|
10
10
|
spec:
|
|
11
11
|
replicas: 1
|
|
12
|
+
strategy:
|
|
13
|
+
type: Recreate
|
|
14
|
+
rollingUpdate: null
|
|
12
15
|
selector:
|
|
13
16
|
matchLabels:
|
|
14
17
|
{{- include "scada.match" (dict "app" "site-server" "ctx" .) | nindent 6 }}
|
|
@@ -51,8 +54,17 @@ spec:
|
|
|
51
54
|
{{- end }}
|
|
52
55
|
{{- range .Values.env }}
|
|
53
56
|
- name: {{ .name }}
|
|
57
|
+
{{- if .valueFrom }}
|
|
58
|
+
valueFrom:
|
|
59
|
+
{{- toYaml .valueFrom | nindent 16 }}
|
|
60
|
+
{{- else }}
|
|
54
61
|
value: {{ .value | quote }}
|
|
62
|
+
{{- end }}
|
|
55
63
|
{{- end }}
|
|
64
|
+
{{- with .Values.envFrom }}
|
|
65
|
+
envFrom:
|
|
66
|
+
{{- toYaml . | nindent 12 }}
|
|
67
|
+
{{- end }}
|
|
56
68
|
readinessProbe:
|
|
57
69
|
httpGet:
|
|
58
70
|
path: /api/v1/machines
|
|
@@ -6,29 +6,126 @@
|
|
|
6
6
|
"type": "array",
|
|
7
7
|
"items": {
|
|
8
8
|
"type": "object",
|
|
9
|
-
"required": [
|
|
9
|
+
"required": [
|
|
10
|
+
"name",
|
|
11
|
+
"command"
|
|
12
|
+
],
|
|
10
13
|
"properties": {
|
|
11
|
-
"name": {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
"name": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
|
|
17
|
+
},
|
|
18
|
+
"command": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"minItems": 1,
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "string"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"env": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": {
|
|
28
|
+
"oneOf": [
|
|
29
|
+
{
|
|
30
|
+
"type": [
|
|
31
|
+
"string",
|
|
32
|
+
"number",
|
|
33
|
+
"boolean"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": [
|
|
39
|
+
"valueFrom"
|
|
40
|
+
],
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"properties": {
|
|
43
|
+
"valueFrom": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"minProperties": 1
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
14
52
|
"health": {
|
|
15
|
-
"type": "object",
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": [
|
|
55
|
+
"port"
|
|
56
|
+
],
|
|
57
|
+
"additionalProperties": false,
|
|
16
58
|
"properties": {
|
|
17
|
-
"port": {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
59
|
+
"port": {
|
|
60
|
+
"type": "integer",
|
|
61
|
+
"minimum": 1,
|
|
62
|
+
"maximum": 65535
|
|
63
|
+
},
|
|
64
|
+
"livenessPath": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"pattern": "^/"
|
|
67
|
+
},
|
|
68
|
+
"readinessPath": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"pattern": "^/"
|
|
71
|
+
},
|
|
72
|
+
"initialDelaySeconds": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"minimum": 0
|
|
75
|
+
},
|
|
76
|
+
"periodSeconds": {
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"minimum": 1
|
|
79
|
+
},
|
|
80
|
+
"timeoutSeconds": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 1
|
|
83
|
+
},
|
|
84
|
+
"failureThreshold": {
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"minimum": 1
|
|
87
|
+
}
|
|
24
88
|
}
|
|
25
89
|
},
|
|
26
90
|
"hostDevices": {
|
|
27
|
-
"type": "array",
|
|
28
|
-
"
|
|
91
|
+
"type": "array",
|
|
92
|
+
"uniqueItems": true,
|
|
93
|
+
"items": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$"
|
|
96
|
+
}
|
|
29
97
|
}
|
|
30
98
|
}
|
|
31
99
|
}
|
|
100
|
+
},
|
|
101
|
+
"extraObjects": {
|
|
102
|
+
"type": "array",
|
|
103
|
+
"items": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"required": [
|
|
106
|
+
"apiVersion",
|
|
107
|
+
"kind",
|
|
108
|
+
"metadata"
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"postgres": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"properties": {
|
|
115
|
+
"podTemplate": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"description": "Opaque PodTemplateSpec override for preserving existing storage pods during adoption; empty uses generated defaults."
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"rabbitmq": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"podTemplate": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"description": "Opaque PodTemplateSpec override for preserving existing storage pods during adoption; empty uses generated defaults."
|
|
127
|
+
}
|
|
128
|
+
}
|
|
32
129
|
}
|
|
33
130
|
}
|
|
34
131
|
}
|
package/charts/scada/values.yaml
CHANGED
|
@@ -10,6 +10,8 @@ sink:
|
|
|
10
10
|
workloadLabels: {}
|
|
11
11
|
# Named deployments with command, env, optional resources and hostDevices.
|
|
12
12
|
processes: []
|
|
13
|
+
# Raw Kubernetes manifests; template expressions are not evaluated.
|
|
14
|
+
extraObjects: []
|
|
13
15
|
image: ""
|
|
14
16
|
command: []
|
|
15
17
|
pullSecret: ""
|
|
@@ -17,6 +19,8 @@ machines: []
|
|
|
17
19
|
sites: []
|
|
18
20
|
collectors: []
|
|
19
21
|
env: []
|
|
22
|
+
# Kubernetes envFrom references for the site API.
|
|
23
|
+
envFrom: []
|
|
20
24
|
extraLabels: {}
|
|
21
25
|
service:
|
|
22
26
|
port: 3000
|
|
@@ -26,6 +30,18 @@ postgres:
|
|
|
26
30
|
enabled: true
|
|
27
31
|
image: postgres:16
|
|
28
32
|
secret: scada-postgres
|
|
33
|
+
# Existing PVC: no PVC or volumeClaimTemplate is created.
|
|
34
|
+
existingClaim: ""
|
|
35
|
+
env: {}
|
|
36
|
+
envFrom: []
|
|
37
|
+
command: []
|
|
38
|
+
args: []
|
|
39
|
+
volumeMounts: []
|
|
40
|
+
volumes: []
|
|
41
|
+
readinessProbe: {}
|
|
42
|
+
livenessProbe: {}
|
|
43
|
+
startupProbe: {}
|
|
44
|
+
resources: {}
|
|
29
45
|
database: scada
|
|
30
46
|
storage: 10Gi
|
|
31
47
|
storageClass: ""
|
|
@@ -35,6 +51,8 @@ rabbitmq:
|
|
|
35
51
|
deadLetter: null
|
|
36
52
|
# A named standalone PVC replaces the default volumeClaimTemplates when set.
|
|
37
53
|
claimName: ""
|
|
54
|
+
createClaim: true
|
|
55
|
+
podAnnotations: {}
|
|
38
56
|
configName: ""
|
|
39
57
|
rolloutOnConfigChange: false
|
|
40
58
|
definitions: null
|
package/index.js
CHANGED
|
@@ -105,3 +105,5 @@ export { default as pointPaths } from './src/application/deploy/pointPaths.js';
|
|
|
105
105
|
export { clusterOf, serverFor } from './src/application/deploy/releaseNodes.js';
|
|
106
106
|
export { default as executeRelease } from './src/infrastructure/deploy/executeRelease.js';
|
|
107
107
|
export { default as collectorProcesses } from './src/application/deploy/collectorProcesses.js';
|
|
108
|
+
|
|
109
|
+
export { default as executeStages } from './src/infrastructure/deploy/executeStages.js';
|
package/package.json
CHANGED
|
@@ -160,6 +160,7 @@ function draftOne(server, paths, mappings) {
|
|
|
160
160
|
id: server.id,
|
|
161
161
|
objects,
|
|
162
162
|
ownership: server.ownership,
|
|
163
|
+
modules: server.modules,
|
|
163
164
|
patternsIn: remote,
|
|
164
165
|
patternsOut: server.fan_out || [],
|
|
165
166
|
profile: server.profile,
|
|
@@ -238,6 +239,9 @@ function materialize(server) {
|
|
|
238
239
|
if (server.service) {
|
|
239
240
|
row.service = server.service;
|
|
240
241
|
}
|
|
242
|
+
if (server.modules) {
|
|
243
|
+
row.modules = server.modules;
|
|
244
|
+
}
|
|
241
245
|
return row;
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -19,7 +19,7 @@ function isMap(value) {
|
|
|
19
19
|
* @example
|
|
20
20
|
* mergeMaps({ secret: 'pg' }, { image: 'plant:1' });
|
|
21
21
|
*/
|
|
22
|
-
function mergeMaps(left, right) {
|
|
22
|
+
export function mergeMaps(left, right) {
|
|
23
23
|
const value = { ...(left || {}) };
|
|
24
24
|
Object.keys(right || {}).forEach((key) => {
|
|
25
25
|
const next = right[key];
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mergeMaps } from './mergeDocuments.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Reads an object entry as a path.
|
|
3
5
|
*
|
|
@@ -114,7 +116,7 @@ export function profileFor(document, server) {
|
|
|
114
116
|
throw new Error(`system document has no profile ${label}`);
|
|
115
117
|
}
|
|
116
118
|
const values = { ...(profile.variables || {}), ...(server.profile.variables || {}) };
|
|
117
|
-
return { ...profile, variables: values };
|
|
119
|
+
return { ...profile, ...(server.modules && { modules: mergeMaps(profile.modules, server.modules) }), variables: values };
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/** Selects an explicitly named service or the sole service of the source. */
|
|
@@ -13,7 +13,8 @@ import { clusterOf, objectPath, serverById, serviceOf } from './releaseNodes.js'
|
|
|
13
13
|
*/
|
|
14
14
|
function bindOne(name, spec, secret) {
|
|
15
15
|
if (name === 'state_store') {
|
|
16
|
-
return ['postgres', {
|
|
16
|
+
return ['postgres', { ...spec.chart, ...(spec.pgdata !== undefined && { data: spec.pgdata }),
|
|
17
|
+
enabled: spec.enabled !== false, secret: secret || 'scada-postgres' }];
|
|
17
18
|
}
|
|
18
19
|
if (name === 'message_bus') {
|
|
19
20
|
return ['rabbitmq', { ...spec.chart, enabled: spec.enabled !== false,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ingestHealth from '../ingestHealth.js';
|
|
2
2
|
import { clickhouseSink } from '../../infrastructure/streams/index.js';
|
|
3
|
+
import mqttMetrics from '../../infrastructure/ingest/mqtt/mqttMetrics.js';
|
|
3
4
|
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
4
5
|
import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
|
|
5
6
|
import operationsFromPg from '../../infrastructure/persistence/pg/operations.js';
|
|
@@ -14,7 +15,7 @@ import alertPipeline from '../../infrastructure/ingest/pipelines/alertPipeline.j
|
|
|
14
15
|
import resolveSecrets from './resolveSecrets.js';
|
|
15
16
|
|
|
16
17
|
function urlOptions(spec) {
|
|
17
|
-
const protocols = { amqp: ['amqp:', 'amqps:'], stomp: ['stomp:', 'stomps:'],
|
|
18
|
+
const protocols = { mqtt: ['mqtt:', 'mqtts:', 'ws:', 'wss:'], amqp: ['amqp:', 'amqps:'], stomp: ['stomp:', 'stomps:'],
|
|
18
19
|
postgres: ['postgres:', 'postgresql:'], clickhouse: ['http:', 'https:'] };
|
|
19
20
|
Object.entries(protocols).forEach(([key, allowed]) => {
|
|
20
21
|
const value = spec[key];
|
|
@@ -58,6 +59,20 @@ function metrics(spec, pool, context) {
|
|
|
58
59
|
const sink = telemetry(spec, pool);
|
|
59
60
|
return ownedSink(amqpMetricsIngest(spec.amqp, spec.queue, sink, { ...boundedOptions(spec), budget: context.budget }), sink);
|
|
60
61
|
}
|
|
62
|
+
function mqttPostgres(spec, pool, context) {
|
|
63
|
+
let failed = false;
|
|
64
|
+
context.report?.({ state: 'degraded' });
|
|
65
|
+
return mqttMetrics(spec.mqtt, metricsSinkFromPool(pool), spec.topic, {
|
|
66
|
+
...boundedOptions(spec), budget: context.budget,
|
|
67
|
+
onReady() { if (!failed) { context.report?.({ state: 'running' }); } },
|
|
68
|
+
onError(error) {
|
|
69
|
+
// An uncertain write retires the executor. Reconnect cannot safely
|
|
70
|
+
// reuse the poisoned batch or overlap SQL still running in its pool.
|
|
71
|
+
failed ||= ['WRITE_TIMEOUT', 'OPERATION_TIMEOUT'].includes(error.code);
|
|
72
|
+
context.report?.({ state: failed ? 'failed' : 'degraded', fatal: failed });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
61
76
|
function silent(spec, pool, context) {
|
|
62
77
|
const sink = telemetry(spec, pool);
|
|
63
78
|
const streams = silentStreams({ budget: spec.budget, interval: spec.interval,
|
|
@@ -143,6 +158,7 @@ export default function additionalAdapters() {
|
|
|
143
158
|
return operationSyncIngest(spec.amqp, spec.queue, operationsFromPg(pool), { ...boundedOptions(spec), budget: context.budget });
|
|
144
159
|
}, ['amqp', 'queue', 'postgres'])],
|
|
145
160
|
['amqp-pg-metrics', descriptor(metrics, ['amqp', 'queue', 'postgres'])],
|
|
161
|
+
['mqtt-pg-metrics', descriptor(mqttPostgres, ['mqtt', 'topic', 'postgres'])],
|
|
146
162
|
['silent-streams', descriptor(silent, ['amqp', 'queue', 'sources'], silentConfig, (spec) => {
|
|
147
163
|
return [...new Set(spec.sources.map((source) => { return `modbus-tcp:${source.host}:${source.port}`; }))];
|
|
148
164
|
})],
|
|
@@ -4,7 +4,9 @@ import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
|
4
4
|
function response(runtime, histogram, request, result) {
|
|
5
5
|
if (!['/live', '/ready', '/status'].includes(request.url)) { result.writeHead(404).end(); return; }
|
|
6
6
|
const state = runtime.status();
|
|
7
|
-
const ready = !state.fatal && !state.stopping
|
|
7
|
+
const ready = !state.fatal && !state.stopping && !state.tasks.some((task) => {
|
|
8
|
+
return task.state === 'failed' || ['failed', 'degraded'].includes(task.health);
|
|
9
|
+
});
|
|
8
10
|
const body = request.url === '/status' ? { ...state, memory: process.memoryUsage(), eventLoopP99Ms: histogram.percentile(99) / 1e6 } : { ready };
|
|
9
11
|
result.writeHead((request.url === '/ready' && !ready) || (request.url === '/live' && state.fatal) ? 503 : 200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
10
12
|
result.end(JSON.stringify(body));
|
|
@@ -22,6 +22,13 @@ function validate(plan, action) {
|
|
|
22
22
|
}
|
|
23
23
|
const fields = action === 'render' ? ['chart', 'release', 'namespace'] : ['chart', 'release', 'namespace', 'context', 'fieldManager'];
|
|
24
24
|
validateRetirements(plan);
|
|
25
|
+
if (plan.podsAbsentBeforeApply !== undefined && (!Array.isArray(plan.podsAbsentBeforeApply)
|
|
26
|
+
|| plan.podsAbsentBeforeApply.some((selector) => {
|
|
27
|
+
return typeof selector !== 'string' || !/^[A-Za-z0-9_./-]+=[A-Za-z0-9_.-]+(?:,[A-Za-z0-9_./-]+=[A-Za-z0-9_.-]+)*$/u.test(selector);
|
|
28
|
+
}))) { throw new Error('podsAbsentBeforeApply requires exact equality label selectors'); }
|
|
29
|
+
if (plan.beforeApply !== undefined && typeof plan.beforeApply !== 'function') {
|
|
30
|
+
throw new Error('beforeApply must be a function');
|
|
31
|
+
}
|
|
25
32
|
fields.forEach((key) => {
|
|
26
33
|
if (typeof plan[key] !== 'string' || !plan[key].trim()) { throw new Error(`release requires ${key}`); }
|
|
27
34
|
});
|
|
@@ -63,8 +70,21 @@ function preflightRetirements(plan, action, kube, manifests) {
|
|
|
63
70
|
}
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
/** Live gates run only for mutating actions, after all staged dry-runs. */
|
|
74
|
+
function gateApply(plan, action, kube) {
|
|
75
|
+
if (['apply', 'adopt'].includes(action)) {
|
|
76
|
+
for (const selector of plan.podsAbsentBeforeApply || []) {
|
|
77
|
+
if (kube(['get', 'pods', '-l', selector, '-o', 'name'], undefined, true).trim()) {
|
|
78
|
+
throw new Error(`pods remain for ${selector}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
plan.beforeApply?.(plan, kube);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
/** Adopt legacy fields, then enforce the complete manifest before waiting for rollouts. */
|
|
67
86
|
function applyRelease(plan, action, kube, manifests) {
|
|
87
|
+
gateApply(plan, action, kube);
|
|
68
88
|
preflightRetirements(plan, action, kube, manifests);
|
|
69
89
|
if (['adopt', 'check-adoption'].includes(action)) {
|
|
70
90
|
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], action === 'check-adoption');
|
|
@@ -86,6 +106,8 @@ function applyRelease(plan, action, kube, manifests) {
|
|
|
86
106
|
* resource/name references. Optional retirements are { workload: "deployment/name",
|
|
87
107
|
* selector: "app=label" }; callers bind these to the old declarative app. They are
|
|
88
108
|
* removed after preflight and before any writes, with no selected pods remaining.
|
|
109
|
+
* podsAbsentBeforeApply equality selectors must have no pods before any mutation.
|
|
110
|
+
* beforeApply(plan, kube) provides caller-owned live gates; throwing stops writes.
|
|
89
111
|
* afterReady(plan, kube) runs after a successful apply.
|
|
90
112
|
*/
|
|
91
113
|
export default function executeRelease(plan, action = 'render', execute = execFileSync) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import executeRelease from './executeRelease.js';
|
|
3
|
+
|
|
4
|
+
/** Execute ordered full release plans, preflighting every stage before mutations.
|
|
5
|
+
* Stage rollout/afterReady and beforeApply gates must succeed before continuing.
|
|
6
|
+
* Read-only actions execute each stage without invoking mutation gates.
|
|
7
|
+
*/
|
|
8
|
+
export default function executeStages(stages, action = 'render', execute = execFileSync) {
|
|
9
|
+
if (!Array.isArray(stages) || !stages.length) { throw new Error('stages must be a nonempty array'); }
|
|
10
|
+
if (['apply', 'adopt'].includes(action)) {
|
|
11
|
+
for (const stage of stages) {
|
|
12
|
+
// SSA can claim legacy fields only after the stage's CSA handoff.
|
|
13
|
+
// Validate all stages without mutating their current ownership first.
|
|
14
|
+
executeRelease(stage, action === 'adopt' ? 'check-adoption' : 'check', execute);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return stages.map((stage) => { return executeRelease(stage, action, execute); });
|
|
18
|
+
}
|
|
@@ -3,8 +3,9 @@ import path from 'path';
|
|
|
3
3
|
import { migrationApplies, migrationSinkRunnable } from './migrationProfile.js';
|
|
4
4
|
/* eslint-disable no-await-in-loop */
|
|
5
5
|
|
|
6
|
-
function migrationSelected(name, profile, privileged) {
|
|
7
|
-
if (!name.endsWith('.sql') || !migrationApplies(name, profile)
|
|
6
|
+
function migrationSelected(name, profile, privileged, options) {
|
|
7
|
+
if (!name.endsWith('.sql') || !migrationApplies(name, profile)
|
|
8
|
+
|| (name === 'V0009__collector_task_state.sql' && options.runtimeState !== true)) {
|
|
8
9
|
return false;
|
|
9
10
|
}
|
|
10
11
|
const sinkRunnable = migrationSinkRunnable(name);
|
|
@@ -20,10 +21,10 @@ async function ensureMigrationsTable(pool) {
|
|
|
20
21
|
`);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
async function applyMigrationFiles(pool, migrationsDir, profile, privileged) {
|
|
24
|
+
async function applyMigrationFiles(pool, migrationsDir, profile, privileged, options = {}) {
|
|
24
25
|
const files = (await fs.readdir(migrationsDir))
|
|
25
26
|
.filter((name) => {
|
|
26
|
-
return migrationSelected(name, profile, privileged);
|
|
27
|
+
return migrationSelected(name, profile, privileged, options);
|
|
27
28
|
})
|
|
28
29
|
.sort((left, right) => {
|
|
29
30
|
return left.localeCompare(right);
|
|
@@ -52,16 +53,17 @@ async function applyMigrationFiles(pool, migrationsDir, profile, privileged) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* Applies SQL migrations runnable as supervisor_sink.
|
|
56
|
+
* Applies SQL migrations runnable as supervisor_sink. Collector state is opt-in.
|
|
56
57
|
*
|
|
57
58
|
* @param {object} pool - pg.Pool instance.
|
|
58
59
|
* @param {string} migrationsDir - absolute path to migration files.
|
|
59
60
|
* @param {string} [profile] - central or edge; filters E/C prefixed files
|
|
61
|
+
* @param {object} [options] - runtimeState: true explicitly includes collector task state
|
|
60
62
|
* @returns {Promise<void>}
|
|
61
63
|
*/
|
|
62
|
-
export default async function migrate(pool, migrationsDir, profile = 'edge') {
|
|
64
|
+
export default async function migrate(pool, migrationsDir, profile = 'edge', options = {}) {
|
|
63
65
|
await ensureMigrationsTable(pool);
|
|
64
|
-
await applyMigrationFiles(pool, migrationsDir, profile, false);
|
|
66
|
+
await applyMigrationFiles(pool, migrationsDir, profile, false, options);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -198,6 +198,32 @@ function oneWindow(rows, plans, config) {
|
|
|
198
198
|
* @returns {Array<Array<{point: string, topic: string}>>} plans
|
|
199
199
|
*/
|
|
200
200
|
function plansFor(rows, config) {
|
|
201
|
+
if (config.plans !== undefined) {
|
|
202
|
+
if (!Array.isArray(config.plans) || config.plans.length !== rows.length
|
|
203
|
+
|| config.plans.some((plan) => { return !Array.isArray(plan) || plan.length === 0; })) {
|
|
204
|
+
throw new TypeError('Publish plans must contain one non-empty array per device');
|
|
205
|
+
}
|
|
206
|
+
if (config.maps?.length) { throw new TypeError('Explicit plans cannot be combined with MAPPING'); }
|
|
207
|
+
const topics = new Set();
|
|
208
|
+
return config.plans.map((plan) => {
|
|
209
|
+
return plan.map((item) => {
|
|
210
|
+
if (!item || typeof item.point !== 'string' || typeof item.topic !== 'string'
|
|
211
|
+
|| !item.topic.trim() || /[+#\0]/u.test(item.topic)) {
|
|
212
|
+
throw new TypeError('Publish plan requires a register point and a concrete MQTT topic');
|
|
213
|
+
}
|
|
214
|
+
if (!/^holding:\d+(?::(?:abcd|cdab))?$/u.test(item.point)) {
|
|
215
|
+
throw new TypeError(`Invalid register point: ${item.point}`);
|
|
216
|
+
}
|
|
217
|
+
const parsed = parsePoint(item.point);
|
|
218
|
+
if (!Number.isSafeInteger(parsed.address) || parsed.address + parsed.width > 65536) {
|
|
219
|
+
throw new TypeError(`Register point exceeds holding-register bounds: ${item.point}`);
|
|
220
|
+
}
|
|
221
|
+
if (topics.has(item.topic)) { throw new TypeError(`Duplicate publish topic: ${item.topic}`); }
|
|
222
|
+
topics.add(item.topic);
|
|
223
|
+
return { point: item.point, topic: item.topic };
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
201
227
|
return rows.map((row, index) => {
|
|
202
228
|
return measurementPlans(streamOf(config, index), mapOf(config, index));
|
|
203
229
|
});
|
|
@@ -225,7 +251,9 @@ function assertPublish(mqtt, config) {
|
|
|
225
251
|
}
|
|
226
252
|
|
|
227
253
|
/**
|
|
228
|
-
* Polls Modbus and publishes
|
|
254
|
+
* Polls Modbus and publishes explicit per-device plans or STREAM and MAPPING.
|
|
255
|
+
* config.plans is an array of [{ point, topic }] arrays aligned with devices.
|
|
256
|
+
* Explicit plans replace generated mappings; combining the two is rejected.
|
|
229
257
|
*
|
|
230
258
|
* A point is `holding:<pdu>`, `holding:<pdu>:abcd`, or `holding:<pdu>:cdab`.
|
|
231
259
|
* The topic is the document path `stream/parameter`. Devices come from
|
|
@@ -47,10 +47,17 @@ export default function mqttMetrics(mqtt, sink, topic, config) {
|
|
|
47
47
|
if (!config || typeof config !== 'object') {
|
|
48
48
|
throw new Error('Config must be an object');
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
// The source owns one lease through durable acceptance. Reserving again in
|
|
51
|
+
// the batch would reject every message at a one-record runtime budget.
|
|
52
|
+
const { budget, ...batchConfig } = config;
|
|
53
|
+
const collector = ingestBatch(sink, { ...batchConfig, size: 1 });
|
|
51
54
|
const transformer = metricsCodec(collector);
|
|
52
55
|
const source = mqttSource(mqtt, topic, transformer, {
|
|
53
|
-
budget
|
|
56
|
+
budget,
|
|
57
|
+
onError: config.onError,
|
|
58
|
+
onReady: config.onReady,
|
|
59
|
+
...(config.acceptTimeoutMs === undefined ? {} : { acceptTimeoutMs: config.acceptTimeoutMs }),
|
|
60
|
+
...(config.stopTimeoutMs === undefined ? {} : { stopTimeoutMs: config.stopTimeoutMs }),
|
|
54
61
|
prefetch: config.prefetch,
|
|
55
62
|
maxPacketBytes: config.maxBytes,
|
|
56
63
|
clientId: config.clientId,
|
|
@@ -10,6 +10,11 @@ function report(options, error) {
|
|
|
10
10
|
}
|
|
11
11
|
function bind(live, collector, options) {
|
|
12
12
|
live.client.on('error', (error) => { report(options, error); });
|
|
13
|
+
for (const event of ['offline', 'close']) {
|
|
14
|
+
live.client.on(event, () => {
|
|
15
|
+
if (live.active) { report(options, new Error('MQTT source disconnected')); }
|
|
16
|
+
});
|
|
17
|
+
}
|
|
13
18
|
// MQTT.js serializes handleMessage and sends PUBACK/PUBCOMP only on successful completion.
|
|
14
19
|
live.client.handleMessage = (packet, callback) => {
|
|
15
20
|
const accepted = Promise.resolve().then(() => {
|
|
@@ -34,7 +39,17 @@ function open(url, list, collector, options) {
|
|
|
34
39
|
const live = { client, active: true, pending: new Set(), controller: new AbortController() };
|
|
35
40
|
bind(live, collector, options);
|
|
36
41
|
client.on('connect', () => {
|
|
37
|
-
if (live.active) {
|
|
42
|
+
if (live.active) {
|
|
43
|
+
client.subscribe(list, { qos: options.qos ?? (options.clientId ? 1 : 0) }, (error, granted) => {
|
|
44
|
+
if (!live.active) { return; }
|
|
45
|
+
if (error || granted?.some((entry) => { return entry.qos >= 128; })) {
|
|
46
|
+
report(options, error ?? new Error('MQTT subscription denied')); return;
|
|
47
|
+
}
|
|
48
|
+
try { options.onReady?.(); } catch {
|
|
49
|
+
// Diagnostics cannot break transport callbacks.
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
38
53
|
});
|
|
39
54
|
return live;
|
|
40
55
|
}
|