@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.
package/dist/plugins/index.js
CHANGED
|
@@ -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;
|