@yarkivaev/scada 2.3.115 → 2.4.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 +158 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +14 -5
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +90 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +70 -0
- package/src/infrastructure/opcua/source.js +38 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import deadline from './deadline.js';
|
|
2
|
+
import stateDocument from './stateDocument.js';
|
|
3
|
+
|
|
4
|
+
async function save(ctx, changes) {
|
|
5
|
+
if (ctx.failed) { throw new Error('Task state requires reload'); }
|
|
6
|
+
ctx.options.signal?.throwIfAborted();
|
|
7
|
+
const next = { ...ctx.data, ...changes };
|
|
8
|
+
try {
|
|
9
|
+
const version = await ctx.store.commit({ ...next, expectedVersion: ctx.data.version, ownership: ctx.options.ownership });
|
|
10
|
+
ctx.options.signal?.throwIfAborted();
|
|
11
|
+
Object.assign(ctx, { data: { ...next, version } });
|
|
12
|
+
} catch (error) { Object.assign(ctx, { failed: true }); throw error; }
|
|
13
|
+
}
|
|
14
|
+
async function resume(ctx, sinks) {
|
|
15
|
+
if (ctx.failed) { throw new Error('Task state requires reload'); }
|
|
16
|
+
if (!ctx.data) { throw new Error('Task state must be opened'); }
|
|
17
|
+
if (!ctx.data.pending) { return; }
|
|
18
|
+
const { event, remaining, checkpoint } = ctx.data.pending;
|
|
19
|
+
for (const name of remaining) {
|
|
20
|
+
ctx.options.signal?.throwIfAborted();
|
|
21
|
+
if (!sinks[name]) { throw new Error('Missing required sink'); }
|
|
22
|
+
// Sequential fan-out persists every durable acknowledgement before continuing.
|
|
23
|
+
// eslint-disable-next-line no-await-in-loop
|
|
24
|
+
await deadline(() => { return sinks[name].write([event], { signal: ctx.options.signal }); }, ctx.options.writeTimeoutMs ?? 10000);
|
|
25
|
+
const pending = { event, checkpoint, remaining: ctx.data.pending.remaining.filter((item) => { return item !== name; }) };
|
|
26
|
+
// eslint-disable-next-line no-await-in-loop
|
|
27
|
+
await save(ctx, { pending });
|
|
28
|
+
}
|
|
29
|
+
await save(ctx, { checkpoint, pending: null });
|
|
30
|
+
}
|
|
31
|
+
function exclusive(ctx, action) {
|
|
32
|
+
if (ctx.busy) { return Promise.reject(new Error('Task state operation in progress')); }
|
|
33
|
+
ctx.busy = true;
|
|
34
|
+
return Promise.resolve().then(action).finally(() => { ctx.busy = false; });
|
|
35
|
+
}
|
|
36
|
+
async function open(ctx) {
|
|
37
|
+
const saved = await ctx.store.load(ctx.options.taskId);
|
|
38
|
+
if (saved && (saved.stateVersion !== ctx.options.stateVersion || saved.compatibilityKey !== ctx.options.compatibilityKey)) {
|
|
39
|
+
throw new Error('Recovered task state is incompatible');
|
|
40
|
+
}
|
|
41
|
+
ctx.options.signal?.throwIfAborted();
|
|
42
|
+
Object.assign(ctx, { data: saved ?? { taskId: ctx.options.taskId, version: 0, stateVersion: ctx.options.stateVersion,
|
|
43
|
+
compatibilityKey: ctx.options.compatibilityKey, checkpoint: null, pending: null } });
|
|
44
|
+
Object.assign(ctx, { failed: false });
|
|
45
|
+
}
|
|
46
|
+
async function deliver(ctx, event, sinks, checkpoint) {
|
|
47
|
+
if (ctx.failed) { throw new Error('Task state requires reload'); }
|
|
48
|
+
if (!ctx.data) { throw new Error('Task state must be opened'); }
|
|
49
|
+
if (ctx.data.pending) { throw new Error('Pending delivery must be resumed first'); }
|
|
50
|
+
if (typeof event.eventId !== 'string' || !event.eventId || Object.keys(sinks).length === 0) {
|
|
51
|
+
throw new TypeError('Stable eventId and required sinks are mandatory');
|
|
52
|
+
}
|
|
53
|
+
const pending = stateDocument({ event, remaining: Object.keys(sinks), checkpoint }, ctx.options.maxBytes ?? 1048576);
|
|
54
|
+
await save(ctx, { pending });
|
|
55
|
+
await resume(ctx, sinks);
|
|
56
|
+
}
|
|
57
|
+
/** Durable per-recipient outbox. CAS detects conflicts; it is not distributed fencing. */
|
|
58
|
+
export default function taskState(store, options) {
|
|
59
|
+
const ctx = { store, options, data: null, busy: false, failed: false };
|
|
60
|
+
return {
|
|
61
|
+
open() { return exclusive(ctx, () => { return open(ctx); }); },
|
|
62
|
+
deliver(event, sinks, checkpoint) { return exclusive(ctx, () => { return deliver(ctx, event, sinks, checkpoint); }); },
|
|
63
|
+
resume(sinks) { return exclusive(ctx, () => { return resume(ctx, sinks); }); },
|
|
64
|
+
checkpoint(value) {
|
|
65
|
+
return exclusive(ctx, () => {
|
|
66
|
+
if (!ctx.data) { throw new Error('Task state must be opened'); }
|
|
67
|
+
if (ctx.data.pending) { throw new Error('Pending delivery must be resumed first'); }
|
|
68
|
+
return save(ctx, { checkpoint: stateDocument(value, options.maxBytes ?? 1048576) });
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
snapshot() { return structuredClone(ctx.data); }
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function external(config) {
|
|
2
|
+
const mode = config.collectorMode ?? config.env?.COLLECTOR_MODE ?? process.env.COLLECTOR_MODE ?? 'embedded';
|
|
3
|
+
if (!['embedded', 'external'].includes(mode)) { throw new TypeError('Invalid collectorMode'); }
|
|
4
|
+
return mode === 'external';
|
|
5
|
+
}
|
|
6
|
+
/** Keep site HTTP/state services while moving autonomous ingestion to executors. */
|
|
7
|
+
export function collectionEnvironment(config) {
|
|
8
|
+
const env = config.env ?? process.env;
|
|
9
|
+
return external(config) ? { ...env, SINK_PIPELINES: 'false' } : env;
|
|
10
|
+
}
|
|
11
|
+
export function startCollection(config, start) {
|
|
12
|
+
if (external(config)) { return undefined; }
|
|
13
|
+
return start();
|
|
14
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clickhouseSink } from '
|
|
1
|
+
import { clickhouseSink } from '../infrastructure/streams/index.js';
|
|
2
2
|
import supervisorSink from './supervisorSink.js';
|
|
3
3
|
import plantServer from './plantServer.js';
|
|
4
4
|
import plantOperations from './plantOperations.js';
|
|
@@ -11,6 +11,7 @@ import startTelemetryIngest from './siteTelemetry.js';
|
|
|
11
11
|
import timelineOperatorFromEnv from './timelineOperatorFromEnv.js';
|
|
12
12
|
import { buildSiteOperatorCatalog } from './siteOperatorCatalog.js';
|
|
13
13
|
import siteSyncBind from './sync/siteSyncBind.js';
|
|
14
|
+
import { collectionEnvironment, startCollection } from './siteCollection.js';
|
|
14
15
|
|
|
15
16
|
function stompFromEnv(env) {
|
|
16
17
|
return {
|
|
@@ -118,7 +119,7 @@ function siteExtraRoutes(catalog, extraRoutes, syncRoutes) {
|
|
|
118
119
|
* await siteServer({ plantFactory: ({ alerts, userDecisions }, sink) => edgePlant(machines, { metrics: sink.dataAccess.metrics, alerts, userDecisions }), kindSources: { temp } });
|
|
119
120
|
*/
|
|
120
121
|
export default async function siteServer(config) {
|
|
121
|
-
const env = config
|
|
122
|
+
const env = collectionEnvironment(config);
|
|
122
123
|
const sink = supervisorSink(env);
|
|
123
124
|
const ops = bindSiteOperations(sink, config.kindSources || config.operationSources);
|
|
124
125
|
const http = edgeApi(sink.dataAccess, {
|
|
@@ -132,15 +133,15 @@ export default async function siteServer(config) {
|
|
|
132
133
|
runRetention
|
|
133
134
|
});
|
|
134
135
|
await sink.run(http);
|
|
135
|
-
const mqtt = startMqtt(sink, env);
|
|
136
|
-
const telemetry = startTelemetryIngest(
|
|
136
|
+
const mqtt = startCollection(config, () => { return startMqtt(sink, env); });
|
|
137
|
+
const telemetry = startCollection(config, () => { return startTelemetryIngest(
|
|
137
138
|
env,
|
|
138
139
|
config.streams,
|
|
139
140
|
mqttConfigFromEnv(env),
|
|
140
141
|
sink.pool,
|
|
141
142
|
config.intervalDevices
|
|
142
|
-
);
|
|
143
|
-
const operationSync = startOperationSync(sink, env);
|
|
143
|
+
); });
|
|
144
|
+
const operationSync = startCollection(config, () => { return startOperationSync(sink, env); });
|
|
144
145
|
const basePath = config.basePath || '/api/v1';
|
|
145
146
|
const catalog = buildSiteOperatorCatalog(config, basePath, sink, env);
|
|
146
147
|
if (catalog.sync) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clickhouseSink } from '
|
|
1
|
+
import { clickhouseSink } from '../infrastructure/streams/index.js';
|
|
2
2
|
import amqpMetricsIngest from '../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
3
3
|
import foldedMetricsSink from './foldedMetricsSink.js';
|
|
4
4
|
import bindSilentStreams from './bindSilentStreams.js';
|
|
@@ -15,7 +15,7 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Reads deployment environment into a normalized config object.
|
|
18
|
-
* `SINK_PIPELINES=false` leaves Postgres and the state HTTP API in this process
|
|
18
|
+
* `COLLECTOR_MODE=external` or `SINK_PIPELINES=false` leaves Postgres and the state HTTP API in this process
|
|
19
19
|
* and leaves the STOMP consumers to another container.
|
|
20
20
|
*
|
|
21
21
|
* @param {NodeJS.ProcessEnv} env - process environment
|
|
@@ -41,7 +41,7 @@ export function readDeploymentConfig(env) {
|
|
|
41
41
|
retentionDays: parseInt(env.RETENTION_DAYS || '30', 10),
|
|
42
42
|
migrationsDir: path.join(root, 'db', 'migrations'),
|
|
43
43
|
requestTimeoutMs: parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS),
|
|
44
|
-
pipelines: env.SINK_PIPELINES !== 'false',
|
|
44
|
+
pipelines: env.COLLECTOR_MODE !== 'external' && env.SINK_PIPELINES !== 'false',
|
|
45
45
|
pipeline: {
|
|
46
46
|
prefetch,
|
|
47
47
|
deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
|
|
@@ -60,6 +60,12 @@ export function readDeploymentConfig(env) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function startPipelines(cfg, pool) {
|
|
64
|
+
const pipelines = [segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline),
|
|
65
|
+
decisionPipeline(cfg.stomp, pool, cfg.pipeline), alertPipeline(cfg.stomp, pool, cfg.pipeline)];
|
|
66
|
+
return Promise.all(pipelines.map((pipeline) => { return pipeline.start(); }));
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
/**
|
|
64
70
|
* Wires STOMP pipelines, migrations, and state data access from env config.
|
|
65
71
|
* HTTP binding uses edgeApi (metrics, checkpoint, retention).
|
|
@@ -70,9 +76,6 @@ export function readDeploymentConfig(env) {
|
|
|
70
76
|
export default function supervisorSink(env = process.env) {
|
|
71
77
|
const cfg = readDeploymentConfig(env);
|
|
72
78
|
const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
|
|
73
|
-
const segments = segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline);
|
|
74
|
-
const decisions = decisionPipeline(cfg.stomp, pool, cfg.pipeline);
|
|
75
|
-
const alerts = alertPipeline(cfg.stomp, pool, cfg.pipeline);
|
|
76
79
|
const metricsEnabled = cfg.sinkDbProfile === 'edge';
|
|
77
80
|
const dataAccess = stateDataFromPool(pool, { metricsEnabled });
|
|
78
81
|
return {
|
|
@@ -97,9 +100,7 @@ export default function supervisorSink(env = process.env) {
|
|
|
97
100
|
await adminPool.end();
|
|
98
101
|
}
|
|
99
102
|
if (cfg.pipelines) {
|
|
100
|
-
await
|
|
101
|
-
decisions.start();
|
|
102
|
-
alerts.start();
|
|
103
|
+
await startPipelines(cfg, pool);
|
|
103
104
|
}
|
|
104
105
|
if (httpServer) {
|
|
105
106
|
httpServer.start();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clickhouseSink } from '
|
|
1
|
+
import { clickhouseSink } from '../../infrastructure/streams/index.js';
|
|
2
2
|
import { jobRoutes, jobs } from '@yarkivaev/simple-server';
|
|
3
3
|
import exportQuery from '../export/exportQuery.js';
|
|
4
4
|
import scadaClient from '../../infrastructure/client/scadaClient.js';
|
|
@@ -3,12 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Polls ADDRESS or MODBUS_DEVICES and publishes STREAM + MAPPING document paths.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
process.stderr.write(`Unhandled rejection: ${String(err)}\n`);
|
|
8
|
-
process.exit(1);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
import modbusPublish from '../infrastructure/ingest/mqtt/modbusPublish.js';
|
|
6
|
+
import { createRegistry, runCollector } from '../application/runtime/index.js';
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* Parses a JSON array environment value.
|
|
@@ -41,7 +36,7 @@ function integer(text) {
|
|
|
41
36
|
}
|
|
42
37
|
|
|
43
38
|
const addresses = jsonList(process.env.ADDRESS);
|
|
44
|
-
const
|
|
39
|
+
const config = {
|
|
45
40
|
address: integer(process.env.MODBUS_ADDRESS),
|
|
46
41
|
addresses,
|
|
47
42
|
clientId: process.env.CLIENT_ID,
|
|
@@ -52,6 +47,13 @@ const pipeline = modbusPublish(process.env.MQTT_URL, {
|
|
|
52
47
|
streams: jsonList(process.env.STREAM) || [],
|
|
53
48
|
threshold: parseInt(process.env.CIRCUIT_THRESHOLD || '5', 10),
|
|
54
49
|
timeout: parseInt(process.env.CIRCUIT_TIMEOUT || '60', 10)
|
|
55
|
-
}
|
|
50
|
+
};
|
|
56
51
|
|
|
57
|
-
|
|
52
|
+
try {
|
|
53
|
+
const env = { ...process.env, COLLECTOR_MANIFEST: JSON.stringify({ assignments: [{ taskId: 'modbus', generation: 0, specVersion: 1,
|
|
54
|
+
spec: { adapter: 'modbus', url: { $secret: 'MQTT_URL' }, config } }] }) };
|
|
55
|
+
await runCollector({ registry: createRegistry(), env, argv: [] });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
process.stderr.write(`collector: ${error.name}\n`);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
/** Retire only explicit Deployments with a single exact app label selector. */
|
|
4
|
+
function validateRetirements(plan) {
|
|
5
|
+
if (plan.retirements !== undefined && !Array.isArray(plan.retirements)) {
|
|
6
|
+
throw new Error('retirements must be an array');
|
|
7
|
+
}
|
|
8
|
+
for (const retirement of plan.retirements || []) {
|
|
9
|
+
const name = retirement?.workload?.match(/^deployment\/(?<name>[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/u)?.groups.name;
|
|
10
|
+
const label = retirement?.selector?.match(/^app=(?<label>[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)$/u)?.groups.label;
|
|
11
|
+
if (!name || name.length > 253 || name.split('.').some((part) => { return !part || part.length > 63; })
|
|
12
|
+
|| !label || label.length > 63 || (plan.workloads || []).includes(retirement.workload)) {
|
|
13
|
+
throw new Error('retirement requires an exact old deployment and app label outside desired workloads');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
/** Validates the target before helm or kubectl can fall back to a default. */
|
|
4
19
|
function validate(plan, action) {
|
|
5
20
|
if (!['render', 'check', 'diff', 'apply', 'adopt', 'check-adoption'].includes(action)) {
|
|
6
21
|
throw new Error('action must be render, check, diff or apply (adopt/check-adoption for migration)');
|
|
7
22
|
}
|
|
8
23
|
const fields = action === 'render' ? ['chart', 'release', 'namespace'] : ['chart', 'release', 'namespace', 'context', 'fieldManager'];
|
|
24
|
+
validateRetirements(plan);
|
|
9
25
|
fields.forEach((key) => {
|
|
10
26
|
if (typeof plan[key] !== 'string' || !plan[key].trim()) { throw new Error(`release requires ${key}`); }
|
|
11
27
|
});
|
|
@@ -13,9 +29,9 @@ function validate(plan, action) {
|
|
|
13
29
|
|
|
14
30
|
/** Every Kubernetes command, including the post-rollout hook, shares this target. */
|
|
15
31
|
function kubeAt(plan, execute) {
|
|
16
|
-
return (args, input) => {
|
|
32
|
+
return (args, input, capture = false) => {
|
|
17
33
|
return execute('kubectl', ['--context', plan.context, '--namespace', plan.namespace, ...args],
|
|
18
|
-
{ input, encoding: 'utf8', stdio: ['pipe', 'inherit', 'inherit'] });
|
|
34
|
+
{ input, encoding: 'utf8', stdio: ['pipe', capture ? 'pipe' : 'inherit', 'inherit'] });
|
|
19
35
|
};
|
|
20
36
|
}
|
|
21
37
|
|
|
@@ -25,8 +41,31 @@ function applyWith(kube, manifests, flags, checkOnly) {
|
|
|
25
41
|
if (!checkOnly) { kube(args, manifests); }
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/** A missing Deployment can still have terminating pods from an interrupted attempt. */
|
|
45
|
+
function retireWorkloads(plan, kube) {
|
|
46
|
+
for (const { workload, selector } of plan.retirements || []) {
|
|
47
|
+
if (kube(['get', workload, '--ignore-not-found', '-o', 'name'], undefined, true).trim()) {
|
|
48
|
+
kube(['delete', workload, '--cascade=foreground', '--wait=true', '--timeout=300s']);
|
|
49
|
+
}
|
|
50
|
+
if (kube(['get', 'pods', '-l', selector, '-o', 'name'], undefined, true).trim()) {
|
|
51
|
+
throw new Error(`retirement pods remain for ${workload} (${selector})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function preflightRetirements(plan, action, kube, manifests) {
|
|
57
|
+
if (['apply', 'adopt'].includes(action) && plan.retirements?.length) {
|
|
58
|
+
if (action === 'adopt') {
|
|
59
|
+
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], true);
|
|
60
|
+
}
|
|
61
|
+
applyWith(kube, manifests, ['--server-side', `--field-manager=${plan.fieldManager}`], true);
|
|
62
|
+
retireWorkloads(plan, kube);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
28
66
|
/** Adopt legacy fields, then enforce the complete manifest before waiting for rollouts. */
|
|
29
67
|
function applyRelease(plan, action, kube, manifests) {
|
|
68
|
+
preflightRetirements(plan, action, kube, manifests);
|
|
30
69
|
if (['adopt', 'check-adoption'].includes(action)) {
|
|
31
70
|
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], action === 'check-adoption');
|
|
32
71
|
if (action === 'check-adoption') { return ''; }
|
|
@@ -44,7 +83,10 @@ function applyRelease(plan, action, kube, manifests) {
|
|
|
44
83
|
* Renders a Helm release and executes the selected kubectl action synchronously.
|
|
45
84
|
* Supply chart, release, namespace and values. Cluster actions additionally require
|
|
46
85
|
* an explicit context and fieldManager. prerequisites and workloads are kubectl
|
|
47
|
-
* resource/name references.
|
|
86
|
+
* resource/name references. Optional retirements are { workload: "deployment/name",
|
|
87
|
+
* selector: "app=label" }; callers bind these to the old declarative app. They are
|
|
88
|
+
* removed after preflight and before any writes, with no selected pods remaining.
|
|
89
|
+
* afterReady(plan, kube) runs after a successful apply.
|
|
48
90
|
*/
|
|
49
91
|
export default function executeRelease(plan, action = 'render', execute = execFileSync) {
|
|
50
92
|
validate(plan, action);
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
clickhouseSink,
|
|
5
|
-
clock,
|
|
6
|
-
lokiSource,
|
|
7
|
-
timedBatch
|
|
8
|
-
} from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { clock } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import { clickhouseSink, lokiSource } from '../../streams/index.js';
|
|
3
|
+
import ingestBatch from '../ingestBatch.js';
|
|
9
4
|
import activityCodec from './activityTransformer.js';
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
7
|
* Pipeline for streaming Loki HTTP access logs to ClickHouse.
|
|
13
8
|
*
|
|
14
|
-
* Polls Loki
|
|
15
|
-
* before
|
|
16
|
-
*
|
|
9
|
+
* Polls Loki and durably inserts each accepted log into ClickHouse
|
|
10
|
+
* before advancing the serial source cursor.
|
|
11
|
+
* Bounds pending records and bytes and waits for durable writes.
|
|
17
12
|
*
|
|
18
13
|
* @example
|
|
19
14
|
* const pipeline = activityTracking(
|
|
@@ -31,10 +26,10 @@ import activityCodec from './activityTransformer.js';
|
|
|
31
26
|
* @param {string} query - LogQL query string
|
|
32
27
|
* @param {object} config - Pipeline configuration
|
|
33
28
|
* @param {number} config.poll - Loki polling interval in seconds
|
|
34
|
-
* @param {number} config.size -
|
|
29
|
+
* @param {number} config.size - Legacy option; serial durable acceptance writes one record at a time
|
|
35
30
|
* @param {number} config.interval - Seconds before time-based flush
|
|
36
|
-
* @param {number} config.threshold -
|
|
37
|
-
* @param {number} config.timeout -
|
|
31
|
+
* @param {number} config.threshold - Legacy option (retained for configuration compatibility)
|
|
32
|
+
* @param {number} config.timeout - Legacy option (use writeTimeoutMs for write deadlines)
|
|
38
33
|
* @returns {object} Pipeline with start() and stop() methods
|
|
39
34
|
*/
|
|
40
35
|
export default function activityTracking(loki, clickhouse, query, config) {
|
|
@@ -51,24 +46,23 @@ export default function activityTracking(loki, clickhouse, query, config) {
|
|
|
51
46
|
throw new Error('Config must be an object');
|
|
52
47
|
}
|
|
53
48
|
const clk = clock();
|
|
54
|
-
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
55
49
|
const sink = clickhouseSink(clickhouse, 'activity.http_access');
|
|
56
|
-
const collector =
|
|
50
|
+
const collector = ingestBatch(sink, { ...config, size: 1 });
|
|
57
51
|
const transformer = activityCodec(collector);
|
|
58
|
-
const source = lokiSource(loki, query, config.poll, transformer, clk);
|
|
52
|
+
const source = lokiSource(loki, query, config.poll, transformer, clk, { ...config.lokiOptions, budget: config.budget });
|
|
59
53
|
return {
|
|
60
54
|
/**
|
|
61
55
|
* Starts the pipeline.
|
|
62
56
|
*/
|
|
63
57
|
start() {
|
|
64
|
-
source.start();
|
|
58
|
+
return source.start();
|
|
65
59
|
},
|
|
66
60
|
/**
|
|
67
61
|
* Stops the pipeline.
|
|
68
62
|
*/
|
|
69
|
-
stop() {
|
|
70
|
-
source.stop();
|
|
71
|
-
collector.stop();
|
|
63
|
+
async stop() {
|
|
64
|
+
await source.stop();
|
|
65
|
+
try { await collector.stop(); } finally { await sink.stop?.(); }
|
|
72
66
|
}
|
|
73
67
|
};
|
|
74
68
|
}
|
|
@@ -22,24 +22,25 @@ export default function activityCodec(collector) {
|
|
|
22
22
|
* @param {object} entry - Log entry with ts and line
|
|
23
23
|
*/
|
|
24
24
|
accept(entry) {
|
|
25
|
+
let log;
|
|
25
26
|
try {
|
|
26
|
-
|
|
27
|
-
if (!log.StartUTC || !log.RequestMethod || !log.RequestPath) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
collector.accept({
|
|
31
|
-
ts: new Date(log.StartUTC).getTime(),
|
|
32
|
-
user: log['request_Remote-User'] || '',
|
|
33
|
-
email: log['request_Remote-Email'] || '',
|
|
34
|
-
method: log.RequestMethod || '',
|
|
35
|
-
path: log.RequestPath || '',
|
|
36
|
-
status: parseInt(log.DownstreamStatus, 10) || 0,
|
|
37
|
-
duration: parseFloat(log.Duration) / 1e9 || 0,
|
|
38
|
-
ip: log.ClientHost || ''
|
|
39
|
-
});
|
|
27
|
+
log = JSON.parse(entry.line);
|
|
40
28
|
} catch {
|
|
41
|
-
|
|
29
|
+
return undefined;
|
|
42
30
|
}
|
|
31
|
+
if (!log || !log.StartUTC || !log.RequestMethod || !log.RequestPath) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return collector.accept({
|
|
35
|
+
ts: new Date(log.StartUTC).getTime(),
|
|
36
|
+
user: log['request_Remote-User'] || '',
|
|
37
|
+
email: log['request_Remote-Email'] || '',
|
|
38
|
+
method: log.RequestMethod || '',
|
|
39
|
+
path: log.RequestPath || '',
|
|
40
|
+
status: parseInt(log.DownstreamStatus, 10) || 0,
|
|
41
|
+
duration: parseFloat(log.Duration) / 1e9 || 0,
|
|
42
|
+
ip: log.ClientHost || ''
|
|
43
|
+
});
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -1,169 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function connectBroker(url) {
|
|
4
|
-
return amqp.connect(url, { heartbeat: 10 });
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function scheduleLater(fn, pause) {
|
|
8
|
-
return setTimeout(fn, pause);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function clearLater(handle) {
|
|
12
|
-
clearTimeout(handle);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function reportFailure(error) {
|
|
16
|
-
process.stderr.write(`amqp consume failed ${error.message}\n`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function nextDelay(delay) {
|
|
20
|
-
return Math.min(delay * 2, 30000);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function blankState() {
|
|
24
|
-
return {
|
|
25
|
-
generation: 0,
|
|
26
|
-
timer: undefined,
|
|
27
|
-
stopping: false,
|
|
28
|
-
delay: 1000,
|
|
29
|
-
session: undefined
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function running(state) {
|
|
34
|
-
return Boolean(state.session) || (state.generation > 0 && !state.stopping);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function scheduleRetry(ctx, generation) {
|
|
38
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const pause = ctx.state.delay;
|
|
42
|
-
ctx.state.delay = nextDelay(ctx.state.delay);
|
|
43
|
-
ctx.state.timer = ctx.schedule(() => {
|
|
44
|
-
ctx.state.timer = undefined;
|
|
45
|
-
return ctx.open(generation);
|
|
46
|
-
}, pause);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function bindClose(ctx, generation, live) {
|
|
50
|
-
let dropped = false;
|
|
51
|
-
function lose() {
|
|
52
|
-
if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
dropped = true;
|
|
56
|
-
if (ctx.state.session === live) {
|
|
57
|
-
ctx.state.session = undefined;
|
|
58
|
-
}
|
|
59
|
-
scheduleRetry(ctx, generation);
|
|
60
|
-
}
|
|
61
|
-
live.connection.on('error', lose);
|
|
62
|
-
live.connection.on('close', lose);
|
|
63
|
-
live.channel.on('error', lose);
|
|
64
|
-
live.channel.on('close', lose);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function accept(ctx, generation, connection) {
|
|
68
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
69
|
-
await connection.close();
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
return connection.createChannel();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function keep(ctx, generation, connection, channel, tag) {
|
|
76
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
77
|
-
await channel.close();
|
|
78
|
-
await connection.close();
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
const live = { connection, channel, tag };
|
|
82
|
-
ctx.state.session = live;
|
|
83
|
-
ctx.state.delay = 1000;
|
|
84
|
-
bindClose(ctx, generation, live);
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function open(ctx, generation) {
|
|
89
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const connection = await ctx.connect(ctx.url);
|
|
94
|
-
const channel = await accept(ctx, generation, connection);
|
|
95
|
-
if (!channel) {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const tag = await ctx.subscribe(channel);
|
|
99
|
-
await keep(ctx, generation, connection, channel, tag);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
ctx.report(error);
|
|
102
|
-
scheduleRetry(ctx, generation);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function begin(ctx) {
|
|
107
|
-
if (running(ctx.state)) {
|
|
108
|
-
return Promise.resolve();
|
|
109
|
-
}
|
|
110
|
-
ctx.state.stopping = false;
|
|
111
|
-
ctx.state.generation += 1;
|
|
112
|
-
ctx.state.delay = 1000;
|
|
113
|
-
return open(ctx, ctx.state.generation);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function end(ctx) {
|
|
117
|
-
ctx.state.stopping = true;
|
|
118
|
-
ctx.state.generation += 1;
|
|
119
|
-
if (ctx.state.timer) {
|
|
120
|
-
ctx.clear(ctx.state.timer);
|
|
121
|
-
ctx.state.timer = undefined;
|
|
122
|
-
}
|
|
123
|
-
const live = ctx.state.session;
|
|
124
|
-
ctx.state.session = undefined;
|
|
125
|
-
if (!live) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
await live.channel.cancel(live.tag);
|
|
129
|
-
await live.channel.close();
|
|
130
|
-
await live.connection.close();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Keeps one AMQP consumer subscribed across broker restarts.
|
|
135
|
-
*
|
|
136
|
-
* @param {string} url - RabbitMQ AMQP URL
|
|
137
|
-
* @param {function} subscribe - async (channel) => consumerTag
|
|
138
|
-
* @param {object} [options] - connect, schedule, clear, report overrides
|
|
139
|
-
* @returns {object} session with start and stop
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* const session = durableConsume('amqp://localhost', async (channel) => {
|
|
143
|
-
* const tag = await channel.consume('scada.telemetry.ingest', () => {});
|
|
144
|
-
* return tag.consumerTag;
|
|
145
|
-
* });
|
|
146
|
-
* await session.start();
|
|
147
|
-
*/
|
|
148
|
-
export default function durableConsume(url, subscribe, options = {}) {
|
|
149
|
-
const ctx = {
|
|
150
|
-
url,
|
|
151
|
-
subscribe,
|
|
152
|
-
connect: options.connect || connectBroker,
|
|
153
|
-
schedule: options.schedule || scheduleLater,
|
|
154
|
-
clear: options.clear || clearLater,
|
|
155
|
-
report: options.report || reportFailure,
|
|
156
|
-
state: blankState(),
|
|
157
|
-
open(generation) {
|
|
158
|
-
return open(ctx, generation);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
return {
|
|
162
|
-
start() {
|
|
163
|
-
return begin(ctx);
|
|
164
|
-
},
|
|
165
|
-
stop() {
|
|
166
|
-
return end(ctx);
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
}
|
|
1
|
+
// Compatibility facade; transport lifecycle is owned by the scada stream adapters.
|
|
2
|
+
export { amqpSession as default } from '../streams/index.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { boundedBatch } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
3
|
+
/** Shared production limits; acceptance resolves only after the sink write. */
|
|
4
|
+
export default function ingestBatch(sink, config = {}) {
|
|
5
|
+
return boundedBatch(sink, {
|
|
6
|
+
...(config.budget ? { budget: config.budget } : {}),
|
|
7
|
+
size: config.size ?? 100,
|
|
8
|
+
maxRecords: config.maxRecords ?? 1000,
|
|
9
|
+
maxBytes: config.maxBytes ?? 1048576,
|
|
10
|
+
maxWaitMs: config.maxWaitMs ?? (config.interval ?? 5) * 1000,
|
|
11
|
+
writeTimeoutMs: config.writeTimeoutMs ?? 10000
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { modbusSource } from '
|
|
1
|
+
import { modbusSource } from '../../streams/index.js';
|
|
2
2
|
|
|
3
3
|
const layout = [
|
|
4
4
|
{ offset: 0, channel: 'AI1' },
|
|
@@ -34,13 +34,13 @@ export function mx210Metrics(device, collector) {
|
|
|
34
34
|
return {
|
|
35
35
|
accept(registers) {
|
|
36
36
|
const ts = Date.now();
|
|
37
|
-
|
|
38
|
-
collector.accept({
|
|
37
|
+
return Promise.all(layout.map((entry) => {
|
|
38
|
+
return collector.accept({
|
|
39
39
|
topic: `MX210/${device}/GET/${entry.channel}/VALUE`,
|
|
40
40
|
ts,
|
|
41
41
|
value: decode(registers[entry.offset], registers[entry.offset + 1])
|
|
42
42
|
});
|
|
43
|
-
}
|
|
43
|
+
}));
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
46
|
}
|