@daemonitor/plugins 1.1.28 → 1.1.30

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,
@@ -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.30",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",