@daemonitor/plugins 1.1.28 → 1.1.31

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.
@@ -10,6 +10,7 @@ import { createNetScanPlugin } from "./monitoring/NetScanPlugin.js";
10
10
  import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
11
11
  import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
12
12
  import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
13
+ import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
13
14
  // In future, implement other plugin factory functions using the same pattern
14
15
  // import { createOSPlugin } from "./monitoring/OSPlugin.js"
15
16
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
@@ -31,6 +32,7 @@ export default {
31
32
  Snmp: createSnmpPlugin,
32
33
  ThreeCX: createThreeCXPlugin,
33
34
  Tplink: createTplinkPlugin,
35
+ Patroni: createPatroniPlugin,
34
36
  // CloudflarePlugin: createCloudflarePlugin,
35
37
  // EwelinkPlugin: createEwelinkPlugin,
36
38
  // MongoDBPlugin: createMongoDBPlugin,
@@ -2,6 +2,7 @@ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import { hostname } from "os";
5
+ import { readFile } from "fs/promises";
5
6
  const execAsync = promisify(exec);
6
7
  const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 }; // containers.length can be large
7
8
  /**
@@ -38,9 +39,78 @@ export function cleanHealthOutput(raw) {
38
39
  return stripped.length > 300 ? "…" + stripped.slice(-300) : stripped;
39
40
  }
40
41
  const HEALTH_RE = /\((healthy|unhealthy|health: starting|starting)\)/i;
41
- // Previous cumulative NetIO/BlockIO totals per container id, to derive a
42
- // per-second rate across polls. Module-level (one collect per host per cycle).
43
42
  const ioPrev = {};
43
+ /** Sum of rbytes/wbytes across every device in a cgroup v2 io.stat. */
44
+ export function parseIoStat(text) {
45
+ let a = 0, b = 0, seen = false;
46
+ for (const line of text.split("\n")) {
47
+ const r = /\brbytes=(\d+)/.exec(line);
48
+ const w = /\bwbytes=(\d+)/.exec(line);
49
+ if (r || w)
50
+ seen = true;
51
+ if (r)
52
+ a += Number(r[1]);
53
+ if (w)
54
+ b += Number(w[1]);
55
+ }
56
+ return seen ? { a, b } : (text.trim() === "" ? { a: 0, b: 0 } : null);
57
+ }
58
+ /** rx/tx bytes summed over a /proc/<pid>/net/dev, loopback excluded. */
59
+ export function parseNetDev(text) {
60
+ let a = 0, b = 0, seen = false;
61
+ for (const line of text.split("\n")) {
62
+ const m = /^\s*([^:\s]+):\s*(.*)$/.exec(line);
63
+ if (!m || m[1] === "lo")
64
+ continue;
65
+ const f = m[2].trim().split(/\s+/).map(Number);
66
+ // receive: bytes packets errs drop fifo frame compressed multicast | transmit: bytes …
67
+ if (f.length < 9 || !Number.isFinite(f[0]) || !Number.isFinite(f[8]))
68
+ continue;
69
+ a += f[0];
70
+ b += f[8];
71
+ seen = true;
72
+ }
73
+ return seen ? { a, b } : null;
74
+ }
75
+ /** The cgroup v2 path in a /proc/<pid>/cgroup ("0::/system.slice/docker-….scope"), or null on v1. */
76
+ export function cgroupV2Path(text) {
77
+ const m = /^0::(\/.*)$/m.exec(text);
78
+ return m ? m[1].trim() : null;
79
+ }
80
+ /**
81
+ * Byte rate between two cumulative readings. Undefined when there is no usable
82
+ * previous reading: none yet, a different source, or the counter went backwards
83
+ * (container restarted, counters reset).
84
+ */
85
+ export function ioRate(cur, prev, dt) {
86
+ if (prev == null || dt <= 0 || cur < prev)
87
+ return undefined;
88
+ return Math.round((cur - prev) / dt);
89
+ }
90
+ /**
91
+ * Exact cumulative counters for one container, read from the kernel rather than
92
+ * `docker stats`, which prints three significant figures. At "17.8GB" one step
93
+ * is 100MB, so the delta between two 30s polls was almost always zero.
94
+ *
95
+ * Needs the host pid (from inspect). Works for docker and podman, systemd or
96
+ * cgroupfs driver, because the cgroup path comes from the process itself.
97
+ * Either half is null when it can't be read (cgroup v1, a hidepid /proc); the
98
+ * caller falls back to the stats strings for that half.
99
+ */
100
+ async function readExactIo(pid, hostNet) {
101
+ if (!(pid > 0))
102
+ return { net: null, blk: null };
103
+ const read = (p) => readFile(p, "utf8").catch(() => null);
104
+ const [cg, dev] = await Promise.all([read(`/proc/${pid}/cgroup`), hostNet ? Promise.resolve(null) : read(`/proc/${pid}/net/dev`)]);
105
+ const path = cg ? cgroupV2Path(cg) : null;
106
+ const io = path ? await read(`/sys/fs/cgroup${path}/io.stat`) : null;
107
+ return {
108
+ // Host networking shares the host's interfaces; reading them would charge
109
+ // all host traffic to this container. docker stats reports 0 for it, and so do we.
110
+ net: hostNet ? { a: 0, b: 0 } : (dev ? parseNetDev(dev) : null),
111
+ blk: io != null ? parseIoStat(io) : null,
112
+ };
113
+ }
44
114
  // docker stats renders "12MB / 7.02MB" (rx / tx, or read / write). Split + convert.
45
115
  function pairToBytes(value) {
46
116
  const [x, y] = (value || "").split("/").map((p) => p.trim());
@@ -65,7 +135,7 @@ function memToBytes(value) {
65
135
  * 2. `docker stats` — live CPU / memory for running containers (one batch)
66
136
  * 3. `docker inspect` — restart count, health, exit code (one batch)
67
137
  */
68
- async function collectContainers(bin) {
138
+ export async function collectContainers(bin) {
69
139
  const psFmt = '{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}';
70
140
  const { stdout: psOut } = await execAsync(`${bin} ps -a --format '${psFmt}'`, EXEC_OPTS);
71
141
  const containers = [];
@@ -94,12 +164,12 @@ async function collectContainers(bin) {
94
164
  if (!containers.length)
95
165
  return containers;
96
166
  // 2. live stats for running containers (one batched call, best-effort).
97
- // NetIO/BlockIO are CUMULATIVE since container start, so we derive a per-second
98
- // rate from the delta vs the previous poll (first poll → no rate).
167
+ // NetIO/BlockIO are CUMULATIVE since container start; they are kept here only
168
+ // as the fallback for counters the kernel won't give us (see readExactIo), and
169
+ // the rate is derived after inspect, once the pid is known.
170
+ const statsIo = new Map();
99
171
  try {
100
172
  const { stdout: statsOut } = await execAsync(`${bin} stats --no-stream --format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}'`, EXEC_OPTS);
101
- const now = Date.now();
102
- const seen = new Set();
103
173
  for (const line of statsOut.split("\n")) {
104
174
  if (!line.trim())
105
175
  continue;
@@ -112,28 +182,8 @@ async function collectContainers(bin) {
112
182
  c.mem = memToBytes(used);
113
183
  c.memLimit = memToBytes(limit);
114
184
  c.memPercent = parseFloat((memPerc || "").replace("%", "")) || 0;
115
- const net = pairToBytes(netIO); // { a: rx, b: tx }
116
- const blk = pairToBytes(blkIO); // { a: read, b: write }
117
- seen.add(id);
118
- const prev = ioPrev[id];
119
- if (prev && now > prev.ts) {
120
- const dt = (now - prev.ts) / 1000;
121
- // Guard counter resets (container restart): negative delta → skip that field.
122
- if (net.a >= prev.rx)
123
- c.netIn = Math.round((net.a - prev.rx) / dt);
124
- if (net.b >= prev.tx)
125
- c.netOut = Math.round((net.b - prev.tx) / dt);
126
- if (blk.a >= prev.rd)
127
- c.blkRead = Math.round((blk.a - prev.rd) / dt);
128
- if (blk.b >= prev.wr)
129
- c.blkWrite = Math.round((blk.b - prev.wr) / dt);
130
- }
131
- ioPrev[id] = { rx: net.a, tx: net.b, rd: blk.a, wr: blk.b, ts: now };
185
+ statsIo.set(id, { net: pairToBytes(netIO), blk: pairToBytes(blkIO) });
132
186
  }
133
- // Drop prev samples for containers no longer present.
134
- for (const k of Object.keys(ioPrev))
135
- if (!seen.has(k))
136
- delete ioPrev[k];
137
187
  }
138
188
  catch (err) {
139
189
  console.error("docker: stats collection failed:", err.message);
@@ -143,24 +193,52 @@ async function collectContainers(bin) {
143
193
  // template errors with `map has no entry for key "Health"` for containers
144
194
  // without a healthcheck, which would fail the whole batch. Health is parsed
145
195
  // from the ps `Status` string ("(healthy)" / "(unhealthy)") instead.
196
+ // Pid + network mode ride the same call, for the exact IO counters.
197
+ const procInfo = new Map();
146
198
  try {
147
199
  const ids = containers.map((c) => c.id).join(" ");
148
- const inspectFmt = '{{.Id}}|{{.RestartCount}}|{{.State.ExitCode}}';
200
+ const inspectFmt = '{{.Id}}|{{.RestartCount}}|{{.State.ExitCode}}|{{.State.Pid}}|{{.HostConfig.NetworkMode}}';
149
201
  const { stdout: inspOut } = await execAsync(`${bin} inspect --format '${inspectFmt}' ${ids}`, EXEC_OPTS);
150
202
  for (const line of inspOut.split("\n")) {
151
203
  if (!line.trim())
152
204
  continue;
153
- const [fullId, restarts, exitCode] = line.split("|");
205
+ const [fullId, restarts, exitCode, pid, netMode] = line.split("|");
154
206
  const c = byId.get(fullId.slice(0, 12));
155
207
  if (!c)
156
208
  continue;
157
209
  c.restarts = parseInt(restarts, 10) || 0;
158
210
  c.exitCode = parseInt(exitCode, 10) || 0;
211
+ procInfo.set(c.id, { pid: parseInt(pid, 10) || 0, hostNet: (netMode || "").trim() === "host" });
159
212
  }
160
213
  }
161
214
  catch (err) {
162
215
  console.error("docker: inspect collection failed:", err.message);
163
216
  }
217
+ // 3b. Per-second IO rates: exact kernel counters where readable, the rounded
218
+ // stats strings otherwise. Only containers docker stats saw (i.e. running).
219
+ const now = Date.now();
220
+ await Promise.all([...statsIo].map(async ([id, fromStats]) => {
221
+ const c = byId.get(id);
222
+ if (!c)
223
+ return;
224
+ const info = procInfo.get(id);
225
+ const exact = info ? await readExactIo(info.pid, info.hostNet) : { net: null, blk: null };
226
+ const net = exact.net ? { ...exact.net, src: "exact" } : { ...fromStats.net, src: "stats" };
227
+ const blk = exact.blk ? { ...exact.blk, src: "exact" } : { ...fromStats.blk, src: "stats" };
228
+ const prev = ioPrev[id];
229
+ const dt = prev ? (now - prev.ts) / 1000 : 0;
230
+ const pn = prev?.net?.src === net.src ? prev.net : undefined;
231
+ const pb = prev?.blk?.src === blk.src ? prev.blk : undefined;
232
+ c.netIn = ioRate(net.a, pn?.a, dt);
233
+ c.netOut = ioRate(net.b, pn?.b, dt);
234
+ c.blkRead = ioRate(blk.a, pb?.a, dt);
235
+ c.blkWrite = ioRate(blk.b, pb?.b, dt);
236
+ ioPrev[id] = { net, blk, ts: now };
237
+ }));
238
+ // Drop prev samples for containers no longer present.
239
+ for (const k of Object.keys(ioPrev))
240
+ if (!statsIo.has(k))
241
+ delete ioPrev[k];
164
242
  // 4. For UNHEALTHY containers only, read the healthcheck's own last output.
165
243
  //
166
244
  // "1/13 containers unhealthy" says a problem exists and nothing about what it
@@ -0,0 +1,169 @@
1
+ import { hostname } from "node:os";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
+ const DEFAULTS = {
4
+ url: "http://localhost:8008",
5
+ lagWarnBytes: 32 * 1024 * 1024, // 32MB: a busy node catching up
6
+ lagCritBytes: 256 * 1024 * 1024, // 256MB: not keeping up at all
7
+ requestTimeout: 5000,
8
+ };
9
+ /**
10
+ * Roles Patroni reports for a member that is holding the primary. `master` is
11
+ * the pre-3.0 spelling and still shows up on older clusters; `standby_leader` is
12
+ * the leader of a standby cluster, which is a primary as far as placement is
13
+ * concerned even though it is replicating from elsewhere.
14
+ */
15
+ const LEADER_ROLES = new Set(["leader", "master", "primary", "standby_leader"]);
16
+ export function isLeaderRole(role) {
17
+ return LEADER_ROLES.has(String(role || "").toLowerCase());
18
+ }
19
+ async function getJson(url, cfg) {
20
+ const headers = { Accept: "application/json" };
21
+ if (cfg.username) {
22
+ const raw = `${cfg.username}:${cfg.password ?? ""}`;
23
+ headers.Authorization = `Basic ${Buffer.from(raw).toString("base64")}`;
24
+ }
25
+ const res = await fetch(url, {
26
+ headers,
27
+ signal: AbortSignal.timeout(cfg.requestTimeout ?? DEFAULTS.requestTimeout),
28
+ });
29
+ // Patroni answers /patroni with 503 on a node that is up but not healthy (a
30
+ // replica that has lost its leader, a node in maintenance). The BODY is still
31
+ // the node's state, and that state is the whole reason to ask, so read it
32
+ // rather than treating the status code as a failure.
33
+ const text = await res.text();
34
+ let body = null;
35
+ try {
36
+ body = text ? JSON.parse(text) : null;
37
+ }
38
+ catch {
39
+ throw new Error(`non-JSON response from ${url} (HTTP ${res.status})`);
40
+ }
41
+ if (!body && !res.ok)
42
+ throw new Error(`HTTP ${res.status} from ${url}`);
43
+ return body;
44
+ }
45
+ /**
46
+ * Bytes this member is behind the leader.
47
+ *
48
+ * /cluster reports `lag` per member and it is already in bytes, but it comes
49
+ * back as the string "unknown" on a member the DCS cannot currently see. That
50
+ * must stay distinct from 0: "unknown" means no information, and coercing it to
51
+ * zero reports a node nobody can reach as perfectly caught up.
52
+ */
53
+ function lagOf(member) {
54
+ const raw = member?.lag;
55
+ if (raw == null)
56
+ return null;
57
+ const n = Number(raw);
58
+ return Number.isFinite(n) ? n : null;
59
+ }
60
+ function normaliseMembers(cluster) {
61
+ const list = Array.isArray(cluster?.members) ? cluster.members : [];
62
+ return list.map((m) => ({
63
+ name: String(m?.name ?? "unknown"),
64
+ role: String(m?.role ?? "unknown").toLowerCase(),
65
+ state: String(m?.state ?? "unknown").toLowerCase(),
66
+ host: m?.host ?? undefined,
67
+ port: typeof m?.port === "number" ? m.port : undefined,
68
+ apiUrl: m?.api_url ?? undefined,
69
+ timeline: typeof m?.timeline === "number" ? m.timeline : undefined,
70
+ lagBytes: lagOf(m),
71
+ }));
72
+ }
73
+ export async function collectPatroni(cfg) {
74
+ const base = String(cfg.url || DEFAULTS.url).replace(/\/+$/, "");
75
+ // Ask for both, and do not let one failure hide the other. A node whose own
76
+ // /patroni is 503-ing can still return a perfectly good /cluster, and that is
77
+ // the reading that says whether the cluster as a whole has a leader.
78
+ const [selfRes, clusterRes] = await Promise.allSettled([
79
+ getJson(`${base}/patroni`, cfg),
80
+ getJson(`${base}/cluster`, cfg),
81
+ ]);
82
+ if (selfRes.status === "rejected" && clusterRes.status === "rejected") {
83
+ throw new Error(String(selfRes.reason?.message || selfRes.reason));
84
+ }
85
+ const self = selfRes.status === "fulfilled" ? selfRes.value : null;
86
+ const cluster = clusterRes.status === "fulfilled" ? clusterRes.value : null;
87
+ const members = normaliseMembers(cluster);
88
+ const scope = self?.patroni?.scope || cluster?.scope || undefined;
89
+ // Patroni's member name defaults to the hostname but is configurable, and the
90
+ // server matches leader-by-name against it, so report what Patroni calls this
91
+ // node rather than what the OS does.
92
+ const member = self?.patroni?.name || self?.name || hostname();
93
+ const selfRole = String(self?.role ?? "unknown").toLowerCase();
94
+ const leaderMember = members.find((m) => isLeaderRole(m.role));
95
+ const selfMember = members.find((m) => m.name === member);
96
+ return {
97
+ kind: "patroni",
98
+ name: scope || "patroni",
99
+ scope,
100
+ member,
101
+ reachable: true,
102
+ // Self-reported role. The server trusts this over any name matching: a node
103
+ // saying "I am the leader" identifies the leader's SYSTEM exactly, with no
104
+ // guessing about which host a Patroni member name belongs to.
105
+ role: selfRole,
106
+ isLeader: isLeaderRole(selfRole),
107
+ state: String(self?.state ?? "unknown").toLowerCase(),
108
+ timeline: self?.timeline ?? selfMember?.timeline ?? null,
109
+ serverVersion: self?.server_version ?? null,
110
+ patroniVersion: self?.patroni?.version ?? null,
111
+ // A cluster with no leader is either mid-election (seconds) or stuck
112
+ // (forever). The server applies a grace window rather than paging on the gap.
113
+ leader: leaderMember?.name ?? null,
114
+ hasLeader: !!leaderMember,
115
+ // Patroni sets this while the DCS lock is not held by anyone.
116
+ clusterUnlocked: self?.cluster_unlocked === true,
117
+ // Maintenance mode: failover is disabled, so a dead leader will NOT be
118
+ // replaced. Worth surfacing because everything else looks normal.
119
+ paused: self?.pause === true,
120
+ pendingRestart: self?.pending_restart === true,
121
+ lagBytes: selfMember?.lagBytes ?? null,
122
+ lagWarnBytes: cfg.lagWarnBytes ?? DEFAULTS.lagWarnBytes,
123
+ lagCritBytes: cfg.lagCritBytes ?? DEFAULTS.lagCritBytes,
124
+ members,
125
+ memberCount: members.length,
126
+ runningCount: members.filter((m) => m.state === "running" || m.state === "streaming").length,
127
+ // Set when /patroni answered but /cluster did not, or vice versa: the report
128
+ // is partial and the server should not read a missing leader as no leader.
129
+ partial: !self || !cluster,
130
+ timestamp: Date.now(),
131
+ };
132
+ }
133
+ export function createPatroniPlugin() {
134
+ let refreshTimer = null;
135
+ const refreshFn = async (plugin) => {
136
+ const cfg = (plugin.config || {});
137
+ const uid = cfg.uniqueId || "patroni";
138
+ try {
139
+ await plugin.send(await collectPatroni(cfg), uid);
140
+ }
141
+ catch (e) {
142
+ // Report the failure rather than going quiet. Silence from this plugin is
143
+ // indistinguishable from the agent being dead, and "Patroni's API is not
144
+ // answering on this node" is itself a finding worth an alert.
145
+ await plugin.send({
146
+ kind: "patroni",
147
+ name: "patroni",
148
+ member: hostname(),
149
+ reachable: false,
150
+ error: String(e?.message || e).slice(0, 200),
151
+ timestamp: Date.now(),
152
+ }, uid);
153
+ }
154
+ };
155
+ const monitorFn = async (plugin) => {
156
+ await refreshFn(plugin);
157
+ // 30s by default rather than the usual 60s: this is the input to leader
158
+ // placement, so the window in which daemonitor believes the old leader is
159
+ // still the leader is bounded by this interval.
160
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 30000);
161
+ };
162
+ const teardownFn = async () => {
163
+ if (refreshTimer)
164
+ clearInterval(refreshTimer);
165
+ refreshTimer = null;
166
+ };
167
+ return createMonitoringPlugin("patroni", "patroni", "Patroni HA Postgres monitor (leader identity, member roles, replication lag)", async () => { }, monitorFn, refreshFn, teardownFn);
168
+ }
169
+ export default createPatroniPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.28",
3
+ "version": "1.1.31",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",