@glassmkr/crucible 0.7.1 → 0.8.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/dist/alerts/__tests__/rules.test.d.ts +1 -0
- package/dist/alerts/__tests__/rules.test.js +325 -0
- package/dist/alerts/__tests__/rules.test.js.map +1 -0
- package/dist/alerts/rules.d.ts +8 -0
- package/dist/alerts/rules.js +139 -32
- package/dist/alerts/rules.js.map +1 -1
- package/dist/api.d.ts +2 -0
- package/dist/api.js +7 -0
- package/dist/api.js.map +1 -0
- package/dist/collect/__tests__/dmi.test.d.ts +1 -0
- package/dist/collect/__tests__/dmi.test.js +114 -0
- package/dist/collect/__tests__/dmi.test.js.map +1 -0
- package/dist/collect/__tests__/ipmi.test.js +47 -1
- package/dist/collect/__tests__/ipmi.test.js.map +1 -1
- package/dist/collect/__tests__/thermal.test.d.ts +1 -0
- package/dist/collect/__tests__/thermal.test.js +164 -0
- package/dist/collect/__tests__/thermal.test.js.map +1 -0
- package/dist/collect/dmi.d.ts +19 -0
- package/dist/collect/dmi.js +109 -0
- package/dist/collect/dmi.js.map +1 -0
- package/dist/collect/ipmi.d.ts +27 -2
- package/dist/collect/ipmi.js +90 -2
- package/dist/collect/ipmi.js.map +1 -1
- package/dist/collect/thermal.d.ts +10 -0
- package/dist/collect/thermal.js +187 -0
- package/dist/collect/thermal.js.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +51 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/__tests__/capability.test.d.ts +1 -0
- package/dist/lib/__tests__/capability.test.js +87 -0
- package/dist/lib/__tests__/capability.test.js.map +1 -0
- package/dist/lib/__tests__/vendor-sensors.test.d.ts +1 -0
- package/dist/lib/__tests__/vendor-sensors.test.js +49 -0
- package/dist/lib/__tests__/vendor-sensors.test.js.map +1 -0
- package/dist/lib/capability.d.ts +21 -0
- package/dist/lib/capability.js +110 -0
- package/dist/lib/capability.js.map +1 -0
- package/dist/lib/cpu-thermal-chips.d.ts +2 -0
- package/dist/lib/cpu-thermal-chips.js +28 -0
- package/dist/lib/cpu-thermal-chips.js.map +1 -0
- package/dist/lib/types.d.ts +58 -0
- package/dist/lib/vendor-sensors.d.ts +27 -0
- package/dist/lib/vendor-sensors.js +63 -0
- package/dist/lib/vendor-sensors.js.map +1 -0
- package/dist/notify/telegram.js +1 -1
- package/dist/notify/telegram.js.map +1 -1
- package/package.json +16 -1
- package/rule-ids.json +29 -0
- package/.dockerignore +0 -13
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -24
- package/.github/ISSUE_TEMPLATE/no_data.md +0 -26
- package/.github/workflows/docker.yml +0 -53
- package/.github/workflows/publish.yml +0 -25
- package/Dockerfile +0 -59
- package/config/collector.example.yaml +0 -43
- package/docker-compose.yml +0 -26
- package/scripts/sign-release.sh +0 -29
- package/src/__tests__/cli.test.ts +0 -74
- package/src/__tests__/reboot-marker.test.ts +0 -122
- package/src/alerts/evaluator.ts +0 -15
- package/src/alerts/rules.ts +0 -283
- package/src/alerts/state.ts +0 -92
- package/src/cli.ts +0 -112
- package/src/collect/__tests__/ipmi.test.ts +0 -96
- package/src/collect/__tests__/smart.test.ts +0 -68
- package/src/collect/__tests__/system.test.ts +0 -29
- package/src/collect/__tests__/zfs.test.ts +0 -72
- package/src/collect/conntrack.ts +0 -27
- package/src/collect/cpu.ts +0 -92
- package/src/collect/disks.ts +0 -91
- package/src/collect/fd.ts +0 -31
- package/src/collect/io-errors.ts +0 -23
- package/src/collect/io-latency.ts +0 -103
- package/src/collect/ipmi.ts +0 -207
- package/src/collect/memory.ts +0 -30
- package/src/collect/network.ts +0 -193
- package/src/collect/ntp.ts +0 -114
- package/src/collect/os-alerts.ts +0 -43
- package/src/collect/raid.ts +0 -40
- package/src/collect/security.ts +0 -268
- package/src/collect/smart.ts +0 -72
- package/src/collect/system.ts +0 -32
- package/src/collect/systemd.ts +0 -33
- package/src/collect/zfs.ts +0 -66
- package/src/config.ts +0 -65
- package/src/index.ts +0 -221
- package/src/lib/__tests__/parse.test.ts +0 -28
- package/src/lib/exec.ts +0 -16
- package/src/lib/parse.ts +0 -29
- package/src/lib/reboot-marker.ts +0 -88
- package/src/lib/types.ts +0 -226
- package/src/lib/version-check.ts +0 -39
- package/src/lib/version.ts +0 -33
- package/src/metrics-server.ts +0 -123
- package/src/notify/email.ts +0 -69
- package/src/notify/slack.ts +0 -47
- package/src/notify/telegram.ts +0 -65
- package/src/push/forge.ts +0 -109
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -12
package/src/alerts/rules.ts
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
// Alert rules for the collector are identical to the Forge evaluator.
|
|
2
|
-
// Re-export from a shared definition to avoid duplication.
|
|
3
|
-
// For the collector, we use the same 15 rules but with local thresholds from config.
|
|
4
|
-
|
|
5
|
-
import type { Snapshot, AlertResult } from "../lib/types.js";
|
|
6
|
-
import type { Config } from "../config.js";
|
|
7
|
-
|
|
8
|
-
export interface AlertRule {
|
|
9
|
-
type: string;
|
|
10
|
-
evaluate(snap: Snapshot, thresholds: Config["thresholds"]): AlertResult[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const allRules: AlertRule[] = [
|
|
14
|
-
// 1. RAM high
|
|
15
|
-
{ type: "ram_high", evaluate(snap, t) {
|
|
16
|
-
if (!snap.memory?.total_mb) return [];
|
|
17
|
-
const pct = (snap.memory.used_mb / snap.memory.total_mb) * 100;
|
|
18
|
-
if (pct < (t.ram_percent ?? 90)) return [];
|
|
19
|
-
return [{ type: "ram_high", severity: pct >= 95 ? "critical" : "warning",
|
|
20
|
-
title: `RAM usage at ${pct.toFixed(1)}%`,
|
|
21
|
-
message: `Using ${snap.memory.used_mb}MB of ${snap.memory.total_mb}MB. ${snap.memory.available_mb}MB available.`,
|
|
22
|
-
evidence: { used_mb: snap.memory.used_mb, total_mb: snap.memory.total_mb, percent: Math.round(pct * 10) / 10 },
|
|
23
|
-
recommendation: "Check: ps aux --sort=-rss | head -20" }];
|
|
24
|
-
}},
|
|
25
|
-
// 2. Swap active
|
|
26
|
-
{ type: "swap_active", evaluate(snap, t) {
|
|
27
|
-
if (t.swap_alert === false || !snap.memory || snap.memory.swap_used_mb <= 0) return [];
|
|
28
|
-
return [{ type: "swap_active", severity: "warning", title: `Swap in use: ${snap.memory.swap_used_mb}MB`,
|
|
29
|
-
message: "Server is using swap space, indicating memory pressure.",
|
|
30
|
-
evidence: { swap_used_mb: snap.memory.swap_used_mb },
|
|
31
|
-
recommendation: "Check: free -h && ps aux --sort=-rss | head -20" }];
|
|
32
|
-
}},
|
|
33
|
-
// 3. Disk space high
|
|
34
|
-
{ type: "disk_space_high", evaluate(snap, t) {
|
|
35
|
-
if (!snap.disks) return [];
|
|
36
|
-
const threshold = t.disk_percent ?? 85;
|
|
37
|
-
return snap.disks.filter(d => d.percent_used >= threshold).map(d => ({
|
|
38
|
-
type: "disk_space_high", severity: d.percent_used >= 95 ? "critical" as const : "warning" as const,
|
|
39
|
-
title: `Disk ${d.mount} at ${d.percent_used}%`,
|
|
40
|
-
message: `${d.device}: ${d.used_gb}GB of ${d.total_gb}GB used. ${d.available_gb}GB available.`,
|
|
41
|
-
evidence: { device: d.device, mount: d.mount, percent_used: d.percent_used },
|
|
42
|
-
recommendation: "Check: du -sh /* | sort -rh | head -20" }));
|
|
43
|
-
}},
|
|
44
|
-
// 4. CPU iowait
|
|
45
|
-
{ type: "cpu_iowait_high", evaluate(snap, t) {
|
|
46
|
-
if (!snap.cpu || snap.cpu.iowait_percent < (t.iowait_percent ?? 20)) return [];
|
|
47
|
-
return [{ type: "cpu_iowait_high", severity: "warning", title: `CPU iowait at ${snap.cpu.iowait_percent.toFixed(1)}%`,
|
|
48
|
-
message: `High I/O wait: CPU spending ${snap.cpu.iowait_percent.toFixed(1)}% waiting for disk.`,
|
|
49
|
-
evidence: { iowait_percent: snap.cpu.iowait_percent },
|
|
50
|
-
recommendation: "Check: iotop -oP or iostat -x 1 5" }];
|
|
51
|
-
}},
|
|
52
|
-
// 5. OOM kills
|
|
53
|
-
{ type: "oom_kills", evaluate(snap) {
|
|
54
|
-
if (!snap.os_alerts || snap.os_alerts.oom_kills_recent <= 0) return [];
|
|
55
|
-
return [{ type: "oom_kills", severity: "critical", title: `${snap.os_alerts.oom_kills_recent} OOM kill(s)`,
|
|
56
|
-
message: `Kernel OOM killer terminated ${snap.os_alerts.oom_kills_recent} process(es).`,
|
|
57
|
-
evidence: { oom_kills_recent: snap.os_alerts.oom_kills_recent },
|
|
58
|
-
recommendation: "Check: dmesg | grep -i 'out of memory'" }];
|
|
59
|
-
}},
|
|
60
|
-
// 6. SMART failing
|
|
61
|
-
{ type: "smart_failing", evaluate(snap) {
|
|
62
|
-
if (!snap.smart) return [];
|
|
63
|
-
return snap.smart.filter(d => d.health !== "PASSED" || (d.reallocated_sectors && d.reallocated_sectors > 0) || (d.pending_sectors && d.pending_sectors > 0))
|
|
64
|
-
.map(d => ({ type: "smart_failing", severity: "critical" as const,
|
|
65
|
-
title: `SMART failure: ${d.device}`, message: `${d.model}: drive showing signs of failure.`,
|
|
66
|
-
evidence: { device: d.device, health: d.health, reallocated_sectors: d.reallocated_sectors, pending_sectors: d.pending_sectors },
|
|
67
|
-
recommendation: `Back up data. Schedule replacement for ${d.device}.` }));
|
|
68
|
-
}},
|
|
69
|
-
// 7. NVMe wear
|
|
70
|
-
{ type: "nvme_wear_high", evaluate(snap, t) {
|
|
71
|
-
if (!snap.smart) return [];
|
|
72
|
-
const threshold = t.nvme_wear_percent ?? 85;
|
|
73
|
-
return snap.smart.filter(d => d.percentage_used != null && d.percentage_used >= threshold)
|
|
74
|
-
.map(d => ({ type: "nvme_wear_high", severity: d.percentage_used! >= 95 ? "critical" as const : "warning" as const,
|
|
75
|
-
title: `NVMe ${d.device} wear at ${d.percentage_used}%`, message: `${d.model} at ${d.percentage_used}% lifetime wear.`,
|
|
76
|
-
evidence: { device: d.device, percentage_used: d.percentage_used },
|
|
77
|
-
recommendation: "Plan drive replacement." }));
|
|
78
|
-
}},
|
|
79
|
-
// 8. RAID degraded
|
|
80
|
-
{ type: "raid_degraded", evaluate(snap) {
|
|
81
|
-
if (!snap.raid) return [];
|
|
82
|
-
return snap.raid.filter(r => r.degraded || r.failed_disks.length > 0)
|
|
83
|
-
.map(r => ({ type: "raid_degraded", severity: "critical" as const,
|
|
84
|
-
title: `RAID ${r.device} degraded`, message: `${r.device} (${r.level}) degraded. Failed: ${r.failed_disks.join(", ") || "unknown"}.`,
|
|
85
|
-
evidence: { device: r.device, failed_disks: r.failed_disks },
|
|
86
|
-
recommendation: "Replace failed drive immediately." }));
|
|
87
|
-
}},
|
|
88
|
-
// 9. Disk latency
|
|
89
|
-
{ type: "disk_latency_high", evaluate(snap, t) {
|
|
90
|
-
if (!snap.disks) return [];
|
|
91
|
-
return snap.disks.filter(d => {
|
|
92
|
-
if (d.latency_p99_ms == null) return false;
|
|
93
|
-
const thresh = d.device.includes("nvme") ? (t.disk_latency_nvme_ms ?? 50) : (t.disk_latency_hdd_ms ?? 200);
|
|
94
|
-
return d.latency_p99_ms >= thresh;
|
|
95
|
-
}).map(d => ({ type: "disk_latency_high", severity: "warning" as const,
|
|
96
|
-
title: `Disk ${d.device} latency ${d.latency_p99_ms!.toFixed(1)}ms`,
|
|
97
|
-
message: `p99 I/O latency on ${d.device} is high.`,
|
|
98
|
-
evidence: { device: d.device, latency_p99_ms: d.latency_p99_ms },
|
|
99
|
-
recommendation: "Check: iotop -oP" }));
|
|
100
|
-
}},
|
|
101
|
-
// 10. Interface errors
|
|
102
|
-
{ type: "interface_errors", evaluate(snap) {
|
|
103
|
-
if (!snap.network) return [];
|
|
104
|
-
return snap.network.filter(i => (i.rx_errors + i.tx_errors + i.rx_drops + i.tx_drops) > 0)
|
|
105
|
-
.map(i => ({ type: "interface_errors", severity: "warning" as const,
|
|
106
|
-
title: `${i.interface}: errors/drops detected`,
|
|
107
|
-
message: `RX errors=${i.rx_errors}, TX errors=${i.tx_errors}, RX drops=${i.rx_drops}, TX drops=${i.tx_drops}.`,
|
|
108
|
-
evidence: { interface: i.interface, rx_errors: i.rx_errors, tx_errors: i.tx_errors, rx_drops: i.rx_drops, tx_drops: i.tx_drops },
|
|
109
|
-
recommendation: "Check cables and SFP/transceiver." }));
|
|
110
|
-
}},
|
|
111
|
-
// 11. Link speed mismatch
|
|
112
|
-
{ type: "link_speed_mismatch", evaluate(snap) {
|
|
113
|
-
if (!snap.network) return [];
|
|
114
|
-
return snap.network.filter(i => i.speed_mbps > 0 && i.speed_mbps < 1000)
|
|
115
|
-
.map(i => ({ type: "link_speed_mismatch", severity: "warning" as const,
|
|
116
|
-
title: `${i.interface} at ${i.speed_mbps} Mbps`,
|
|
117
|
-
message: `Interface negotiated below 1 Gbps.`,
|
|
118
|
-
evidence: { interface: i.interface, speed_mbps: i.speed_mbps },
|
|
119
|
-
recommendation: "Check cable, SFP, switch port config." }));
|
|
120
|
-
}},
|
|
121
|
-
// 12. Interface saturation
|
|
122
|
-
{ type: "interface_saturation", evaluate(snap, t) {
|
|
123
|
-
if (!snap.network) return [];
|
|
124
|
-
const threshold = (t.interface_utilization_percent ?? 90) / 100;
|
|
125
|
-
return snap.network.filter(i => {
|
|
126
|
-
if (!i.speed_mbps) return false;
|
|
127
|
-
const maxBps = (i.speed_mbps * 1_000_000) / 8;
|
|
128
|
-
return Math.max(i.rx_bytes_sec, i.tx_bytes_sec) / maxBps >= threshold;
|
|
129
|
-
}).map(i => {
|
|
130
|
-
const maxBps = (i.speed_mbps * 1_000_000) / 8;
|
|
131
|
-
const util = Math.max(i.rx_bytes_sec, i.tx_bytes_sec) / maxBps * 100;
|
|
132
|
-
return { type: "interface_saturation", severity: "warning" as const,
|
|
133
|
-
title: `${i.interface} at ${util.toFixed(0)}% utilization`,
|
|
134
|
-
message: `Interface ${i.interface} (${i.speed_mbps} Mbps) near saturation.`,
|
|
135
|
-
evidence: { interface: i.interface, utilization_percent: Math.round(util * 10) / 10 },
|
|
136
|
-
recommendation: "Check: iftop or nload" };
|
|
137
|
-
});
|
|
138
|
-
}},
|
|
139
|
-
// 13. CPU temperature
|
|
140
|
-
{ type: "cpu_temperature_high", evaluate(snap, t) {
|
|
141
|
-
if (!snap.ipmi?.available || !snap.ipmi.sensors) return [];
|
|
142
|
-
const warn = t.cpu_temp_warning_c ?? 80;
|
|
143
|
-
return snap.ipmi.sensors.filter(s => {
|
|
144
|
-
const n = s.name.toLowerCase();
|
|
145
|
-
if (!n.includes("cpu") && !n.includes("temp")) return false;
|
|
146
|
-
const v = typeof s.value === "number" ? s.value : parseFloat(String(s.value));
|
|
147
|
-
return !isNaN(v) && v >= warn;
|
|
148
|
-
}).map(s => {
|
|
149
|
-
const v = typeof s.value === "number" ? s.value : parseFloat(String(s.value));
|
|
150
|
-
const crit = s.upper_critical ?? (t.cpu_temp_critical_c ?? 90);
|
|
151
|
-
return { type: "cpu_temperature_high", severity: v >= crit ? "critical" as const : "warning" as const,
|
|
152
|
-
title: `${s.name}: ${v}${s.unit}`, message: `Temperature above warning threshold.`,
|
|
153
|
-
evidence: { sensor: s.name, value: v },
|
|
154
|
-
recommendation: "Check cooling, fans, airflow." };
|
|
155
|
-
});
|
|
156
|
-
}},
|
|
157
|
-
// 14. ECC errors
|
|
158
|
-
{ type: "ecc_errors", evaluate(snap) {
|
|
159
|
-
if (!snap.ipmi?.ecc_errors) return [];
|
|
160
|
-
const { correctable, uncorrectable } = snap.ipmi.ecc_errors;
|
|
161
|
-
if (correctable <= 0 && uncorrectable <= 0) return [];
|
|
162
|
-
if (uncorrectable > 0) return [{ type: "ecc_errors", severity: "critical",
|
|
163
|
-
title: `${uncorrectable} uncorrectable ECC error(s)`, message: "Data corruption possible. DIMM failing.",
|
|
164
|
-
evidence: { correctable, uncorrectable },
|
|
165
|
-
recommendation: "Replace DIMM immediately. Run: ipmitool sdr type Memory" }];
|
|
166
|
-
return [{ type: "ecc_errors", severity: "warning",
|
|
167
|
-
title: `${correctable} correctable ECC error(s)`, message: "Early warning of DIMM failure.",
|
|
168
|
-
evidence: { correctable, uncorrectable },
|
|
169
|
-
recommendation: "Schedule DIMM replacement. Run: ipmitool sdr type Memory" }];
|
|
170
|
-
}},
|
|
171
|
-
// 15. PSU redundancy
|
|
172
|
-
{ type: "psu_redundancy_loss", evaluate(snap) {
|
|
173
|
-
if (!snap.ipmi?.available || !snap.ipmi.sensors) return [];
|
|
174
|
-
const psus = snap.ipmi.sensors.filter(s => { const n = s.name.toLowerCase(); return n.includes("psu") || n.includes("power supply"); });
|
|
175
|
-
if (psus.length < 2) return [];
|
|
176
|
-
const failed = psus.filter(s => { const st = String(s.status).toLowerCase(); const v = String(s.value).toLowerCase();
|
|
177
|
-
return st.includes("fail") || st.includes("absent") || v.includes("fail") || v.includes("absent"); });
|
|
178
|
-
if (failed.length === 0) return [];
|
|
179
|
-
return [{ type: "psu_redundancy_loss", severity: "critical",
|
|
180
|
-
title: "PSU redundancy lost", message: `${failed.length} PSU(s) failed/absent: ${failed.map(p => p.name).join(", ")}.`,
|
|
181
|
-
evidence: { failed: failed.map(p => ({ name: p.name, status: p.status })) },
|
|
182
|
-
recommendation: "Replace failed PSU. Check power connections." }];
|
|
183
|
-
}},
|
|
184
|
-
// 19. IPMI SEL critical events
|
|
185
|
-
{ type: "ipmi_sel_critical", evaluate(snap) {
|
|
186
|
-
if (!snap.ipmi?.available || !snap.ipmi.sel_events_recent?.length) return [];
|
|
187
|
-
const critical = snap.ipmi.sel_events_recent.filter(e => e.severity === "critical" && e.direction === "Asserted");
|
|
188
|
-
if (critical.length === 0) return [];
|
|
189
|
-
const byType: Record<string, typeof critical> = {};
|
|
190
|
-
for (const e of critical) { if (!byType[e.sensor_type]) byType[e.sensor_type] = []; byType[e.sensor_type].push(e); }
|
|
191
|
-
const details = Object.entries(byType).map(([t, evts]) => `${t}: ${evts.map(e => `${e.sensor}: ${e.event}`).join(", ")}`).join("; ");
|
|
192
|
-
const recs: string[] = [];
|
|
193
|
-
if (byType.memory) recs.push("Memory errors: identify slot with `ipmitool sel elist | grep -i memory`. Schedule DIMM replacement.");
|
|
194
|
-
if (byType.power) recs.push("PSU event: check physical PSU and connections. Verify redundancy: `ipmitool chassis status`.");
|
|
195
|
-
if (byType.watchdog) recs.push("Watchdog reset: OS or BMC became unresponsive. Check dmesg for root cause.");
|
|
196
|
-
if (byType.processor) recs.push("CPU event: check for thermal throttling or MCE. Run `dmesg | grep -i mce`.");
|
|
197
|
-
if (recs.length === 0) recs.push("Review full SEL: `ipmitool sel elist`.");
|
|
198
|
-
return [{ type: "ipmi_sel_critical", severity: "critical",
|
|
199
|
-
title: `IPMI: ${critical.length} critical hardware event(s)`,
|
|
200
|
-
message: `BMC System Event Log: ${critical.length} critical event(s). ${details}`,
|
|
201
|
-
evidence: { critical_events: critical, sensor_types: Object.keys(byType) },
|
|
202
|
-
recommendation: recs.join(" ") }];
|
|
203
|
-
}},
|
|
204
|
-
// 20. Fan failure
|
|
205
|
-
{ type: "ipmi_fan_failure", evaluate(snap) {
|
|
206
|
-
if (!snap.ipmi?.available || !snap.ipmi.fans?.length) return [];
|
|
207
|
-
const failed = snap.ipmi.fans.filter(f => f.status === "critical" || (f.rpm === 0 && f.status !== "absent"));
|
|
208
|
-
if (failed.length === 0) return [];
|
|
209
|
-
const total = snap.ipmi.fans.filter(f => f.status !== "absent").length;
|
|
210
|
-
const names = failed.map(f => `${f.name} (${f.rpm} RPM)`).join(", ");
|
|
211
|
-
return [{ type: "ipmi_fan_failure", severity: "critical",
|
|
212
|
-
title: `Fan failure: ${failed.length} of ${total} fans`,
|
|
213
|
-
message: `${failed.length} fan(s) stopped or critically slow: ${names}. Reduced cooling capacity.`,
|
|
214
|
-
evidence: { failed_fans: failed, total_fans: total, all_fans: snap.ipmi.fans.filter(f => f.status !== "absent") },
|
|
215
|
-
recommendation: "Check physical fans. Monitor temps: `ipmitool sdr type Temperature`. Replace failed fan module." }];
|
|
216
|
-
}},
|
|
217
|
-
// === Security (6) ===
|
|
218
|
-
// 21. SSH root password login
|
|
219
|
-
{ type: "ssh_root_password", evaluate(snap) {
|
|
220
|
-
if (!snap.security?.ssh?.rootPasswordExposed) return [];
|
|
221
|
-
return [{ type: "ssh_root_password", severity: "warning",
|
|
222
|
-
title: "SSH root login with password enabled",
|
|
223
|
-
message: `PermitRootLogin is "${snap.security.ssh.permitRootLogin}" and PasswordAuthentication is "${snap.security.ssh.passwordAuthentication}". Root can be brute-forced over SSH.`,
|
|
224
|
-
evidence: { permitRootLogin: snap.security.ssh.permitRootLogin, passwordAuthentication: snap.security.ssh.passwordAuthentication },
|
|
225
|
-
recommendation: 'Set "PermitRootLogin prohibit-password" in /etc/ssh/sshd_config and restart sshd. Key-based root login still works.' }];
|
|
226
|
-
}},
|
|
227
|
-
// 22. No firewall
|
|
228
|
-
{ type: "no_firewall", evaluate(snap) {
|
|
229
|
-
if (!snap.security || snap.security.firewall.active) return [];
|
|
230
|
-
return [{ type: "no_firewall", severity: "warning" as const,
|
|
231
|
-
title: "No firewall active",
|
|
232
|
-
message: "No active firewall rules detected (checked UFW, firewalld, nftables, iptables). All ports are exposed unless protected by network-level ACLs.",
|
|
233
|
-
evidence: { source: snap.security.firewall.source },
|
|
234
|
-
recommendation: 'Enable a firewall: "sudo ufw enable" (Debian/Ubuntu) or "sudo systemctl start firewalld" (RHEL/Rocky).' }];
|
|
235
|
-
}},
|
|
236
|
-
// 23. Pending security updates
|
|
237
|
-
{ type: "pending_security_updates", evaluate(snap, t) {
|
|
238
|
-
if (!snap.security?.pending_updates?.available) return [];
|
|
239
|
-
const maxPending = 10;
|
|
240
|
-
if (snap.security.pending_updates.pendingCount <= maxPending) return [];
|
|
241
|
-
const d = snap.security.pending_updates;
|
|
242
|
-
return [{ type: "pending_security_updates", severity: "warning",
|
|
243
|
-
title: `${d.pendingCount} security updates pending`,
|
|
244
|
-
message: `${d.pendingCount} security updates pending on this ${d.distro} server.`,
|
|
245
|
-
evidence: { pendingCount: d.pendingCount, distro: d.distro },
|
|
246
|
-
recommendation: d.distro === "ubuntu" || d.distro === "debian" ? 'Apply with: "sudo apt-get upgrade"' : 'Apply with: "sudo dnf update --security"' }];
|
|
247
|
-
}},
|
|
248
|
-
// 24. Kernel vulnerabilities
|
|
249
|
-
{ type: "kernel_vulnerabilities", evaluate(snap) {
|
|
250
|
-
if (!snap.security?.kernel_vulns?.length) return [];
|
|
251
|
-
const unmitigated = snap.security.kernel_vulns.filter(v => !v.mitigated);
|
|
252
|
-
if (unmitigated.length === 0) return [];
|
|
253
|
-
const details = unmitigated.map(v => `${v.name}: ${v.status}`).join("; ");
|
|
254
|
-
return [{ type: "kernel_vulnerabilities", severity: "warning",
|
|
255
|
-
title: `${unmitigated.length} CPU vulnerability mitigations missing`,
|
|
256
|
-
message: `Unmitigated: ${details}. Update the kernel and CPU microcode to apply mitigations.`,
|
|
257
|
-
evidence: { unmitigated, total: snap.security.kernel_vulns.length },
|
|
258
|
-
recommendation: 'Check: "grep . /sys/devices/system/cpu/vulnerabilities/*". Update kernel and microcode packages.' }];
|
|
259
|
-
}},
|
|
260
|
-
// 25. Kernel needs reboot
|
|
261
|
-
{ type: "kernel_needs_reboot", evaluate(snap) {
|
|
262
|
-
if (!snap.security?.kernel_reboot?.needsReboot) return [];
|
|
263
|
-
const k = snap.security.kernel_reboot;
|
|
264
|
-
return [{ type: "kernel_needs_reboot", severity: "warning" as const,
|
|
265
|
-
title: "Reboot required for kernel update",
|
|
266
|
-
message: `Running kernel: ${k.running}. Installed kernel: ${k.installed}. A reboot is needed to apply the newer kernel.`,
|
|
267
|
-
evidence: { running: k.running, installed: k.installed },
|
|
268
|
-
recommendation: "Schedule a reboot to apply the newer kernel. Security patches may not be active until then." }];
|
|
269
|
-
}},
|
|
270
|
-
// 26. Unattended upgrades disabled
|
|
271
|
-
{ type: "unattended_upgrades_disabled", evaluate(snap) {
|
|
272
|
-
if (!snap.security || snap.security.auto_updates.configured) return [];
|
|
273
|
-
const a = snap.security.auto_updates;
|
|
274
|
-
const hint = a.mechanism === "unattended-upgrades" ? 'Enable: "sudo dpkg-reconfigure -plow unattended-upgrades"'
|
|
275
|
-
: a.mechanism === "dnf-automatic" ? 'Enable: "sudo systemctl enable --now dnf-automatic-install.timer"'
|
|
276
|
-
: 'Install: "sudo apt install unattended-upgrades" (Debian/Ubuntu) or "sudo dnf install dnf-automatic" (RHEL/Rocky)';
|
|
277
|
-
return [{ type: "unattended_upgrades_disabled", severity: "warning" as const,
|
|
278
|
-
title: "Automatic security updates not configured",
|
|
279
|
-
message: `${a.details}. Without automatic updates, security patches must be applied manually.`,
|
|
280
|
-
evidence: { mechanism: a.mechanism, details: a.details },
|
|
281
|
-
recommendation: hint }];
|
|
282
|
-
}},
|
|
283
|
-
];
|
package/src/alerts/state.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
-
import type { AlertResult } from "../lib/types.js";
|
|
3
|
-
|
|
4
|
-
const STATE_FILE = "/var/lib/glassmkr/alert-state.json";
|
|
5
|
-
|
|
6
|
-
interface AlertState {
|
|
7
|
-
type: string;
|
|
8
|
-
first_seen: string;
|
|
9
|
-
last_seen: string;
|
|
10
|
-
notified: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
let state: Map<string, AlertState> = new Map();
|
|
14
|
-
|
|
15
|
-
function load() {
|
|
16
|
-
try {
|
|
17
|
-
const raw = readFileSync(STATE_FILE, "utf-8");
|
|
18
|
-
const data: Record<string, AlertState> = JSON.parse(raw);
|
|
19
|
-
state = new Map(Object.entries(data));
|
|
20
|
-
} catch {
|
|
21
|
-
state = new Map();
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function save() {
|
|
26
|
-
try {
|
|
27
|
-
mkdirSync("/var/lib/glassmkr", { recursive: true });
|
|
28
|
-
const obj: Record<string, AlertState> = {};
|
|
29
|
-
for (const [k, v] of state) obj[k] = v;
|
|
30
|
-
writeFileSync(STATE_FILE, JSON.stringify(obj, null, 2));
|
|
31
|
-
} catch (err) {
|
|
32
|
-
console.error("[state] Failed to save alert state:", err);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Initialize on import
|
|
37
|
-
load();
|
|
38
|
-
|
|
39
|
-
export function updateAlertState(currentAlerts: AlertResult[]): {
|
|
40
|
-
newAlerts: AlertResult[];
|
|
41
|
-
resolvedAlerts: AlertResult[];
|
|
42
|
-
} {
|
|
43
|
-
const now = new Date().toISOString();
|
|
44
|
-
const currentTypes = new Set(currentAlerts.map((a) => a.type));
|
|
45
|
-
const newAlerts: AlertResult[] = [];
|
|
46
|
-
const resolvedAlerts: AlertResult[] = [];
|
|
47
|
-
|
|
48
|
-
// Check for new alerts
|
|
49
|
-
for (const alert of currentAlerts) {
|
|
50
|
-
const existing = state.get(alert.type);
|
|
51
|
-
if (!existing) {
|
|
52
|
-
// New alert
|
|
53
|
-
state.set(alert.type, { type: alert.type, first_seen: now, last_seen: now, notified: false });
|
|
54
|
-
newAlerts.push(alert);
|
|
55
|
-
} else {
|
|
56
|
-
// Existing alert, update last_seen
|
|
57
|
-
existing.last_seen = now;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Check for resolved alerts
|
|
62
|
-
for (const [type, alertState] of state) {
|
|
63
|
-
if (!currentTypes.has(type)) {
|
|
64
|
-
resolvedAlerts.push({
|
|
65
|
-
type,
|
|
66
|
-
severity: "warning",
|
|
67
|
-
title: `Resolved: ${type}`,
|
|
68
|
-
message: `Condition cleared. Active for ${timeSince(alertState.first_seen)}.`,
|
|
69
|
-
evidence: {},
|
|
70
|
-
recommendation: "",
|
|
71
|
-
});
|
|
72
|
-
state.delete(type);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
save();
|
|
77
|
-
return { newAlerts, resolvedAlerts };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function timeSince(isoDate: string): string {
|
|
81
|
-
const ms = Date.now() - new Date(isoDate).getTime();
|
|
82
|
-
const minutes = Math.floor(ms / 60000);
|
|
83
|
-
if (minutes < 60) return `${minutes} minute(s)`;
|
|
84
|
-
const hours = Math.floor(minutes / 60);
|
|
85
|
-
if (hours < 24) return `${hours} hour(s) ${minutes % 60} minute(s)`;
|
|
86
|
-
const days = Math.floor(hours / 24);
|
|
87
|
-
return `${days} day(s)`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function getActiveAlerts(): string[] {
|
|
91
|
-
return Array.from(state.keys());
|
|
92
|
-
}
|
package/src/cli.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// CLI argument handling for the Crucible binary. Runs before any config load
|
|
2
|
-
// or collector initialization so --version and --help exit cleanly even when
|
|
3
|
-
// the config file is missing or the host lacks the tools the collectors need.
|
|
4
|
-
|
|
5
|
-
export type CliMode = "version" | "help" | "run" | "mark-reboot" | "reboot";
|
|
6
|
-
|
|
7
|
-
export interface CliArgs {
|
|
8
|
-
mode: CliMode;
|
|
9
|
-
configPath: string;
|
|
10
|
-
reason?: string;
|
|
11
|
-
ttl?: string; // raw duration string, parsed by caller
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const DEFAULT_CONFIG_PATH = "/etc/glassmkr/collector.yaml";
|
|
15
|
-
|
|
16
|
-
export function parseCliArgs(argv: string[], version: string): { result: CliArgs; output: string | null } {
|
|
17
|
-
// argv is typically process.argv.slice(2)
|
|
18
|
-
let configPath = DEFAULT_CONFIG_PATH;
|
|
19
|
-
|
|
20
|
-
// Subcommand dispatch: `mark-reboot` and `reboot` take their own flags
|
|
21
|
-
// (--reason, --ttl) but re-use --help.
|
|
22
|
-
if (argv[0] === "mark-reboot" || argv[0] === "reboot") {
|
|
23
|
-
const mode: "mark-reboot" | "reboot" = argv[0];
|
|
24
|
-
let reason: string | undefined;
|
|
25
|
-
let ttl: string | undefined;
|
|
26
|
-
for (let i = 1; i < argv.length; i++) {
|
|
27
|
-
const a = argv[i];
|
|
28
|
-
if (a === "--help" || a === "-h") {
|
|
29
|
-
return { result: { mode: "help", configPath: "" }, output: subcommandHelp(mode, version) };
|
|
30
|
-
}
|
|
31
|
-
if (a === "--reason") { reason = argv[++i]; continue; }
|
|
32
|
-
if (a.startsWith("--reason=")) { reason = a.slice("--reason=".length); continue; }
|
|
33
|
-
if (a === "--ttl") { ttl = argv[++i]; continue; }
|
|
34
|
-
if (a.startsWith("--ttl=")) { ttl = a.slice("--ttl=".length); continue; }
|
|
35
|
-
}
|
|
36
|
-
return { result: { mode, configPath: "", reason, ttl }, output: null };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
for (let i = 0; i < argv.length; i++) {
|
|
40
|
-
const arg = argv[i];
|
|
41
|
-
if (arg === "--version" || arg === "-v") {
|
|
42
|
-
return { result: { mode: "version", configPath: "" }, output: `glassmkr-crucible v${version}` };
|
|
43
|
-
}
|
|
44
|
-
if (arg === "--help" || arg === "-h") {
|
|
45
|
-
return { result: { mode: "help", configPath: "" }, output: helpText(version) };
|
|
46
|
-
}
|
|
47
|
-
// -c <path> or --config <path>
|
|
48
|
-
if (arg === "-c" || arg === "--config") {
|
|
49
|
-
const next = argv[i + 1];
|
|
50
|
-
if (next) {
|
|
51
|
-
configPath = next;
|
|
52
|
-
i++;
|
|
53
|
-
}
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
// --config=<path>
|
|
57
|
-
if (arg.startsWith("--config=")) {
|
|
58
|
-
configPath = arg.slice("--config=".length);
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
// Legacy positional argument: first non-flag token
|
|
62
|
-
if (!arg.startsWith("-")) {
|
|
63
|
-
configPath = arg;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { result: { mode: "run", configPath }, output: null };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function helpText(version: string): string {
|
|
71
|
-
return [
|
|
72
|
-
`glassmkr-crucible v${version} - Bare metal server monitoring agent`,
|
|
73
|
-
"",
|
|
74
|
-
"Usage:",
|
|
75
|
-
" glassmkr-crucible [options]",
|
|
76
|
-
" glassmkr-crucible mark-reboot [--reason TEXT] [--ttl DURATION]",
|
|
77
|
-
" glassmkr-crucible reboot [--reason TEXT] [--ttl DURATION]",
|
|
78
|
-
"",
|
|
79
|
-
"Options:",
|
|
80
|
-
" -v, --version Print version and exit",
|
|
81
|
-
" -h, --help Print this help and exit",
|
|
82
|
-
` -c, --config Path to config file (default: ${DEFAULT_CONFIG_PATH})`,
|
|
83
|
-
"",
|
|
84
|
-
"Subcommands:",
|
|
85
|
-
" mark-reboot Write a planned-reboot marker so the next boot",
|
|
86
|
-
" does not fire `server_rebooted_unexpectedly`.",
|
|
87
|
-
" You run the reboot yourself afterwards.",
|
|
88
|
-
" reboot Write the marker, then invoke `systemctl reboot`.",
|
|
89
|
-
"",
|
|
90
|
-
"Without options, starts the collector daemon using the config file.",
|
|
91
|
-
"Docs: https://github.com/glassmkr/crucible",
|
|
92
|
-
].join("\n");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function subcommandHelp(mode: "mark-reboot" | "reboot", version: string): string {
|
|
96
|
-
const action = mode === "reboot"
|
|
97
|
-
? "Write a planned-reboot marker and invoke `systemctl reboot`."
|
|
98
|
-
: "Write a planned-reboot marker; operator triggers the reboot.";
|
|
99
|
-
return [
|
|
100
|
-
`glassmkr-crucible ${mode} - ${action}`,
|
|
101
|
-
"",
|
|
102
|
-
"Usage:",
|
|
103
|
-
` glassmkr-crucible ${mode} [--reason TEXT] [--ttl DURATION]`,
|
|
104
|
-
"",
|
|
105
|
-
"Options:",
|
|
106
|
-
' --reason TEXT Free-text reason (e.g. "kernel update")',
|
|
107
|
-
" --ttl DURATION Expiry window; e.g. 5m, 10m, 1h (default 10m)",
|
|
108
|
-
"",
|
|
109
|
-
`Marker path: /var/lib/crucible/reboot-expected (requires root).`,
|
|
110
|
-
`v${version}`,
|
|
111
|
-
].join("\n");
|
|
112
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { classifySensor, deriveSelSeverity, parseSelTimestamp, parseFanStatus } from "../ipmi.js";
|
|
3
|
-
|
|
4
|
-
describe("classifySensor", () => {
|
|
5
|
-
it("recognizes memory sensors", () => {
|
|
6
|
-
expect(classifySensor("DIMM_A1")).toBe("memory");
|
|
7
|
-
expect(classifySensor("Memory ECC")).toBe("memory");
|
|
8
|
-
});
|
|
9
|
-
it("recognizes power supplies", () => {
|
|
10
|
-
expect(classifySensor("PSU1 Status")).toBe("power");
|
|
11
|
-
expect(classifySensor("Power Supply 1")).toBe("power");
|
|
12
|
-
});
|
|
13
|
-
it("recognizes fans, watchdog, processors, temps, voltage, storage, chassis", () => {
|
|
14
|
-
expect(classifySensor("Fan1")).toBe("fan");
|
|
15
|
-
expect(classifySensor("Watchdog")).toBe("watchdog");
|
|
16
|
-
expect(classifySensor("Processor 0")).toBe("processor");
|
|
17
|
-
// CPU-named temperature sensors classify as processor (cpu check wins over temp).
|
|
18
|
-
expect(classifySensor("CPU1 Temp")).toBe("processor");
|
|
19
|
-
expect(classifySensor("Inlet Temp")).toBe("temperature");
|
|
20
|
-
expect(classifySensor("VCore Voltage")).toBe("voltage");
|
|
21
|
-
expect(classifySensor("Drive Slot 1")).toBe("storage");
|
|
22
|
-
expect(classifySensor("Chassis Intrusion")).toBe("chassis");
|
|
23
|
-
});
|
|
24
|
-
it("falls back to 'other'", () => {
|
|
25
|
-
expect(classifySensor("Weird Sensor")).toBe("other");
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("deriveSelSeverity", () => {
|
|
30
|
-
it("treats uncorrectable, thermal trip, AC lost as critical", () => {
|
|
31
|
-
expect(deriveSelSeverity("Uncorrectable ECC", "memory")).toBe("critical");
|
|
32
|
-
expect(deriveSelSeverity("Thermal trip", "processor")).toBe("critical");
|
|
33
|
-
expect(deriveSelSeverity("AC lost", "power")).toBe("critical");
|
|
34
|
-
expect(deriveSelSeverity("Machine check", "processor")).toBe("critical");
|
|
35
|
-
});
|
|
36
|
-
it("treats correctable ECC and redundancy lost as warning", () => {
|
|
37
|
-
expect(deriveSelSeverity("Correctable ECC", "memory")).toBe("warning");
|
|
38
|
-
expect(deriveSelSeverity("Redundancy lost", "power")).toBe("warning");
|
|
39
|
-
});
|
|
40
|
-
it("treats presence detected as info", () => {
|
|
41
|
-
expect(deriveSelSeverity("Presence detected", "memory")).toBe("info");
|
|
42
|
-
});
|
|
43
|
-
it("defaults to warning for memory/power/fan/processor sensor types", () => {
|
|
44
|
-
expect(deriveSelSeverity("Some odd event", "memory")).toBe("warning");
|
|
45
|
-
expect(deriveSelSeverity("Some odd event", "fan")).toBe("warning");
|
|
46
|
-
});
|
|
47
|
-
it("defaults to info for other sensor types", () => {
|
|
48
|
-
expect(deriveSelSeverity("Some odd event", "other")).toBe("info");
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("parseSelTimestamp", () => {
|
|
53
|
-
it("formats a known date/time", () => {
|
|
54
|
-
expect(parseSelTimestamp("04/05/2026", "14:23:05")).toBe("2026-04-05T14:23:05Z");
|
|
55
|
-
});
|
|
56
|
-
it("pads single digit month/day", () => {
|
|
57
|
-
expect(parseSelTimestamp("4/5/2026", "09:00:00")).toBe("2026-04-05T09:00:00Z");
|
|
58
|
-
});
|
|
59
|
-
it("returns an ISO string for bad input (does not crash)", () => {
|
|
60
|
-
const out = parseSelTimestamp("", "");
|
|
61
|
-
expect(typeof out).toBe("string");
|
|
62
|
-
expect(out.length).toBeGreaterThan(10);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("parseFanStatus", () => {
|
|
67
|
-
it("parses healthy fan output", () => {
|
|
68
|
-
const raw = [
|
|
69
|
-
"FAN1 | 30h | ok | 7.1 | 5000 RPM",
|
|
70
|
-
"FAN2 | 31h | ok | 7.2 | 5100 RPM",
|
|
71
|
-
].join("\n");
|
|
72
|
-
const fans = parseFanStatus(raw);
|
|
73
|
-
expect(fans).toHaveLength(2);
|
|
74
|
-
expect(fans[0]).toMatchObject({ name: "FAN1", rpm: 5000, status: "ok" });
|
|
75
|
-
expect(fans[1].rpm).toBe(5100);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("marks critical fans (cr/nr) as critical", () => {
|
|
79
|
-
const raw = "FAN1 | 30h | cr | 7.1 | 0 RPM";
|
|
80
|
-
const fans = parseFanStatus(raw);
|
|
81
|
-
expect(fans[0].status).toBe("critical");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("marks absent/no-reading fans as absent", () => {
|
|
85
|
-
const raw = "FAN3 | 30h | ns | 7.1 | no reading";
|
|
86
|
-
const fans = parseFanStatus(raw);
|
|
87
|
-
expect(fans[0].status).toBe("absent");
|
|
88
|
-
expect(fans[0].rpm).toBe(0);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("treats 0 RPM with no explicit status as critical", () => {
|
|
92
|
-
const raw = "FAN1 | 30h | 7.1 | 0 RPM";
|
|
93
|
-
const fans = parseFanStatus(raw);
|
|
94
|
-
expect(fans[0].status).toBe("critical");
|
|
95
|
-
});
|
|
96
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseSmartctlJson } from "../smart.js";
|
|
3
|
-
|
|
4
|
-
describe("parseSmartctlJson", () => {
|
|
5
|
-
it("parses a healthy SATA SSD", () => {
|
|
6
|
-
const data = {
|
|
7
|
-
model_name: "Samsung SSD 970 EVO 1TB",
|
|
8
|
-
smart_status: { passed: true },
|
|
9
|
-
temperature: { current: 38 },
|
|
10
|
-
power_on_time: { hours: 9000 },
|
|
11
|
-
ata_smart_attributes: {
|
|
12
|
-
table: [
|
|
13
|
-
{ id: 5, name: "Reallocated_Sector_Ct", raw: { value: 0 } },
|
|
14
|
-
{ id: 197, name: "Current_Pending_Sector", raw: { value: 0 } },
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
};
|
|
18
|
-
const info = parseSmartctlJson(data, "/dev/sda");
|
|
19
|
-
expect(info).toMatchObject({
|
|
20
|
-
device: "/dev/sda",
|
|
21
|
-
model: "Samsung SSD 970 EVO 1TB",
|
|
22
|
-
health: "PASSED",
|
|
23
|
-
temperature_c: 38,
|
|
24
|
-
power_on_hours: 9000,
|
|
25
|
-
reallocated_sectors: 0,
|
|
26
|
-
pending_sectors: 0,
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("parses a failing SATA drive with reallocated sectors", () => {
|
|
31
|
-
const data = {
|
|
32
|
-
model_name: "WD Red 4TB",
|
|
33
|
-
smart_status: { passed: false },
|
|
34
|
-
ata_smart_attributes: {
|
|
35
|
-
table: [
|
|
36
|
-
{ id: 5, raw: { value: 12 } },
|
|
37
|
-
{ id: 197, raw: { value: 3 } },
|
|
38
|
-
],
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
const info = parseSmartctlJson(data, "/dev/sdb");
|
|
42
|
-
expect(info.health).toBe("FAILED");
|
|
43
|
-
expect(info.reallocated_sectors).toBe(12);
|
|
44
|
-
expect(info.pending_sectors).toBe(3);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("parses an NVMe drive with percentage_used", () => {
|
|
48
|
-
const data = {
|
|
49
|
-
model_name: "Samsung 980 PRO",
|
|
50
|
-
smart_status: { passed: true },
|
|
51
|
-
nvme_smart_health_information_log: { percentage_used: 22, temperature: 41 },
|
|
52
|
-
};
|
|
53
|
-
const info = parseSmartctlJson(data, "/dev/nvme0n1");
|
|
54
|
-
expect(info.percentage_used).toBe(22);
|
|
55
|
-
expect(info.temperature_c).toBe(41);
|
|
56
|
-
expect(info.health).toBe("PASSED");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("falls back to 'unknown' model when absent", () => {
|
|
60
|
-
const info = parseSmartctlJson({ smart_status: { passed: true } }, "/dev/sdc");
|
|
61
|
-
expect(info.model).toBe("unknown");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("treats missing smart_status as FAILED (safer default)", () => {
|
|
65
|
-
const info = parseSmartctlJson({}, "/dev/sdd");
|
|
66
|
-
expect(info.health).toBe("FAILED");
|
|
67
|
-
});
|
|
68
|
-
});
|