@yarkivaev/scada 2.4.9 → 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 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 represents admission, not the health of every remote
443
- server. Fatal cleanup makes liveness fail. A blocked event loop cannot answer
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 }}
@@ -0,0 +1,4 @@
1
+ {{- range .Values.extraObjects }}
2
+ ---
3
+ {{ toYaml . }}
4
+ {{- end }}
@@ -11,7 +11,7 @@ spec:
11
11
  ports:
12
12
  - name: postgres
13
13
  port: 5432
14
- targetPort: postgres
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
- - name: POSTGRES_DB
46
- value: {{ .Values.postgres.database | quote }}
47
- - name: POSTGRES_USER
48
- valueFrom:
49
- secretKeyRef:
50
- name: {{ .Values.postgres.secret }}
51
- key: user
52
- - name: POSTGRES_PASSWORD
53
- valueFrom:
54
- secretKeyRef:
55
- name: {{ .Values.postgres.secret }}
56
- key: password
57
- - name: PGDATA
58
- value: {{ .Values.postgres.data | quote }}
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 .health }}
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
- value: {{ $value | quote }}
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
- {{- with .hostDevices }}
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
- 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 }}
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": ["name", "command"],
9
+ "required": [
10
+ "name",
11
+ "command"
12
+ ],
10
13
  "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
+ "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", "required": ["port"], "additionalProperties": false,
53
+ "type": "object",
54
+ "required": [
55
+ "port"
56
+ ],
57
+ "additionalProperties": false,
16
58
  "properties": {
17
- "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
18
- "livenessPath": { "type": "string", "pattern": "^/" },
19
- "readinessPath": { "type": "string", "pattern": "^/" },
20
- "initialDelaySeconds": { "type": "integer", "minimum": 0 },
21
- "periodSeconds": { "type": "integer", "minimum": 1 },
22
- "timeoutSeconds": { "type": "integer", "minimum": 1 },
23
- "failureThreshold": { "type": "integer", "minimum": 1 }
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", "uniqueItems": true,
28
- "items": { "type": "string", "pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$" }
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.4.9",
3
+ "version": "2.5.0",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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', { enabled: spec.enabled !== false, secret: secret || 'scada-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 measurements named by STREAM and MAPPING.
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
- const collector = ingestBatch(sink, { ...config, size: 1 });
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: config.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) { client.subscribe(list, { qos: options.qos ?? (options.clientId ? 1 : 0) }); }
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
  }