Updates
CI / test (push) Successful in 1m4s
Release rolling / release (push) Successful in 7m14s

This commit is contained in:
Raven Scott
2026-07-18 20:01:47 -04:00
parent a639b3c953
commit 52a1823469
7 changed files with 187 additions and 32 deletions
+2 -4
View File
@@ -8,14 +8,12 @@
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { execFile } from '../../utils/exec.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('dmcache') const log = logger.child('dmcache')
const execFileAsync = promisify(execFile)
const CHART_STATS = { const CHART_STATS = {
id: 'dmcache.stats', id: 'dmcache.stats',
@@ -92,7 +90,7 @@ export async function collectDmcacheStats() {
if (!devices.length) return { hits: null, misses: null } if (!devices.length) return { hits: null, misses: null }
try { try {
const { stdout } = await execFileAsync('dmsetup', ['status'], { timeout: 3000 }) const { stdout } = await execFile('dmsetup', ['status'], { timeout: 3000 })
const parsed = parseDmsetupStatus(stdout) const parsed = parseDmsetupStatus(stdout)
if (parsed.hits != null || parsed.misses != null) return parsed if (parsed.hits != null || parsed.misses != null) return parsed
} catch { } catch {
+2 -4
View File
@@ -5,14 +5,12 @@
* *
* Charts: sensors.ipmi.temp.*, sensors.ipmi.fan.* * Charts: sensors.ipmi.temp.*, sensors.ipmi.fan.*
*/ */
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { execFile } from '../../utils/exec.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('ipmi') const log = logger.child('ipmi')
const execFileAsync = promisify(execFile)
export function isIpmiEnabled() { export function isIpmiEnabled() {
const v = process.env.PEARDATA_IPMI const v = process.env.PEARDATA_IPMI
@@ -81,7 +79,7 @@ export function parseIpmitoolSensor(stdout) {
} }
export async function readIpmitoolSensors(timeoutMs = 5000) { export async function readIpmitoolSensors(timeoutMs = 5000) {
const { stdout } = await execFileAsync('ipmitool', ['sensor'], { const { stdout } = await execFile('ipmitool', ['sensor'], {
timeout: timeoutMs, timeout: timeoutMs,
encoding: 'utf8', encoding: 'utf8',
}) })
+2 -4
View File
@@ -5,14 +5,12 @@
* *
* Charts: gpu.util.{i}, gpu.mem.{i}, gpu.temp.{i}, gpu.power.{i} * Charts: gpu.util.{i}, gpu.mem.{i}, gpu.temp.{i}, gpu.power.{i}
*/ */
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { execFile } from '../../utils/exec.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('nvidia') const log = logger.child('nvidia')
const execFileAsync = promisify(execFile)
const QUERY = const QUERY =
'index,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw' 'index,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw'
@@ -92,7 +90,7 @@ export function parseNvidiaSmiCsv(stdout) {
} }
export async function queryNvidiaGpus(timeoutMs = 5000) { export async function queryNvidiaGpus(timeoutMs = 5000) {
const { stdout } = await execFileAsync( const { stdout } = await execFile(
'nvidia-smi', 'nvidia-smi',
[`--query-gpu=${QUERY}`, '--format=csv,noheader,nounits'], [`--query-gpu=${QUERY}`, '--format=csv,noheader,nounits'],
{ timeout: timeoutMs, encoding: 'utf8' } { timeout: timeoutMs, encoding: 'utf8' }
+4 -6
View File
@@ -5,14 +5,12 @@
* *
* Charts: smart.temp.{dev}, smart.reallocated.{dev} * Charts: smart.temp.{dev}, smart.reallocated.{dev}
*/ */
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { execFile } from '../../utils/exec.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('smart') const log = logger.child('smart')
const execFileAsync = promisify(execFile)
const DEVICE_TIMEOUT_MS = 2000 const DEVICE_TIMEOUT_MS = 2000
@@ -58,7 +56,7 @@ function makeSmartChart(dev, kind) {
*/ */
export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) { export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) {
try { try {
const { stdout } = await execFileAsync('smartctl', ['--scan'], { const { stdout } = await execFile('smartctl', ['--scan'], {
timeout: timeoutMs, timeout: timeoutMs,
encoding: 'utf8', encoding: 'utf8',
}) })
@@ -80,7 +78,7 @@ export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) {
*/ */
export async function readSmartAttributes(dev) { export async function readSmartAttributes(dev) {
try { try {
const { stdout } = await execFileAsync('smartctl', ['-A', '-j', dev], { const { stdout } = await execFile('smartctl', ['-A', '-j', dev], {
timeout: DEVICE_TIMEOUT_MS, timeout: DEVICE_TIMEOUT_MS,
encoding: 'utf8', encoding: 'utf8',
}) })
@@ -98,7 +96,7 @@ export async function readSmartAttributes(dev) {
return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null } return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null }
} catch { } catch {
try { try {
const { stdout } = await execFileAsync('smartctl', ['-A', dev], { const { stdout } = await execFile('smartctl', ['-A', dev], {
timeout: DEVICE_TIMEOUT_MS, timeout: DEVICE_TIMEOUT_MS,
encoding: 'utf8', encoding: 'utf8',
}) })
+8 -7
View File
@@ -2,9 +2,9 @@
* Unbound DNS collector via unbound-control or remote HTTP stats. * Unbound DNS collector via unbound-control or remote HTTP stats.
* Enable: PEARDATA_UNBOUND=1 * Enable: PEARDATA_UNBOUND=1
*/ */
import { spawnSync } from 'child_process'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { execFileSync } from '../../utils/exec.js'
export function isUnboundEnabled() { export function isUnboundEnabled() {
const v = process.env.PEARDATA_UNBOUND const v = process.env.PEARDATA_UNBOUND
@@ -40,15 +40,16 @@ export class UnboundCollector extends CollectorPlugin {
} }
} }
if (!Object.keys(stats).length) { if (!Object.keys(stats).length) {
const res = spawnSync('unbound-control', ['stats_noreset'], { try {
encoding: 'utf8', const { stdout } = execFileSync('unbound-control', ['stats_noreset'], {
timeout: 2000, encoding: 'utf8',
}) })
if (res.status === 0 && res.stdout) { for (const line of String(stdout || '').split('\n')) {
for (const line of res.stdout.split('\n')) {
const [k, v] = line.split('=') const [k, v] = line.split('=')
if (k && v != null && Number.isFinite(Number(v))) stats[k.trim()] = Number(v) if (k && v != null && Number.isFinite(Number(v))) stats[k.trim()] = Number(v)
} }
} catch {
// unbound-control unavailable
} }
} }
registerChart({ registerChart({
+8 -7
View File
@@ -4,7 +4,7 @@
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { spawnSync } from 'child_process' import { execFileSync } from '../../utils/exec.js'
import { CollectorPlugin } from './plugin.js' import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
@@ -106,12 +106,13 @@ export class ZfsCollector extends CollectorPlugin {
// zpool list -Hp // zpool list -Hp
try { try {
const res = spawnSync('zpool', ['list', '-Hp', '-o', 'name,size,alloc,free,health'], { const { stdout } = execFileSync(
encoding: 'utf8', 'zpool',
timeout: 2000, ['list', '-Hp', '-o', 'name,size,alloc,free,health'],
}) { encoding: 'utf8' }
if (res.status === 0 && res.stdout) { )
for (const line of res.stdout.trim().split('\n')) { if (stdout) {
for (const line of String(stdout).trim().split('\n')) {
const [name, size, alloc, free, health] = line.split('\t') const [name, size, alloc, free, health] = line.split('\t')
if (!name) continue if (!name) continue
const id = `zfs.pool.${name.replace(/[^\w.-]/g, '_')}` const id = `zfs.pool.${name.replace(/[^\w.-]/g, '_')}`
+161
View File
@@ -0,0 +1,161 @@
/**
* Bare-compatible process helpers.
*
* bare-subprocess (mapped from `child_process` in Bare) provides spawn /
* spawnSync only — not Node's execFile. Collectors should use this module.
*/
import { spawn, spawnSync } from 'child_process'
/**
* @param {unknown} buf
* @param {BufferEncoding|'buffer'|undefined} encoding
*/
function decode(buf, encoding = 'utf8') {
if (buf == null) return encoding === 'buffer' ? Buffer.alloc(0) : ''
if (encoding === 'buffer') {
return Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
}
if (typeof buf === 'string') return buf
return Buffer.from(buf).toString(encoding || 'utf8')
}
/**
* Run a command and return stdout/stderr (Promise), Bare-safe.
*
* @param {string} file
* @param {string[]} [args]
* @param {{
* cwd?: string,
* env?: NodeJS.ProcessEnv,
* timeout?: number,
* encoding?: BufferEncoding|'buffer',
* maxBuffer?: number,
* }} [opts]
* @returns {Promise<{ stdout: string|Buffer, stderr: string|Buffer }>}
*/
export function execFile(file, args = [], opts = {}) {
const encoding = opts.encoding ?? 'utf8'
const timeout = Number(opts.timeout) || 0
const maxBuffer = Number(opts.maxBuffer) || 4 * 1024 * 1024
return new Promise((resolve, reject) => {
let child
try {
child = spawn(file, args, {
cwd: opts.cwd,
env: opts.env,
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (err) {
reject(err)
return
}
/** @type {Buffer[]} */
const outChunks = []
/** @type {Buffer[]} */
const errChunks = []
let outLen = 0
let errLen = 0
let settled = false
/** @type {ReturnType<typeof setTimeout>|null} */
let timer = null
const settle = (err, result) => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
if (err) reject(err)
else resolve(result)
}
if (timeout > 0) {
timer = setTimeout(() => {
try {
child.kill()
} catch {
// ignore
}
const err = new Error(`Command timed out: ${file}`)
err.killed = true
settle(err)
}, timeout)
}
const onChunk = (chunks, which) => (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
if (which === 'out') {
outLen += buf.length
if (outLen > maxBuffer) {
try {
child.kill()
} catch {
// ignore
}
settle(new Error(`stdout maxBuffer exceeded: ${file}`))
return
}
} else {
errLen += buf.length
if (errLen > maxBuffer) {
try {
child.kill()
} catch {
// ignore
}
settle(new Error(`stderr maxBuffer exceeded: ${file}`))
return
}
}
chunks.push(buf)
}
if (child.stdout) child.stdout.on('data', onChunk(outChunks, 'out'))
if (child.stderr) child.stderr.on('data', onChunk(errChunks, 'err'))
child.on('error', (err) => settle(err))
child.on('close', (code, signal) => {
const stdout = decode(Buffer.concat(outChunks), encoding)
const stderr = decode(Buffer.concat(errChunks), encoding)
if (code && code !== 0) {
const err = new Error(`Command failed: ${file} ${args.join(' ')}`)
err.status = code
err.code = code
err.signal = signal
err.stdout = stdout
err.stderr = stderr
settle(err)
return
}
settle(null, { stdout, stderr })
})
})
}
/**
* Synchronous helper for short tools (zfs/unbound-style).
*
* @param {string} file
* @param {string[]} [args]
* @param {{ cwd?: string, env?: NodeJS.ProcessEnv, encoding?: BufferEncoding|'buffer', maxBuffer?: number }} [opts]
*/
export function execFileSync(file, args = [], opts = {}) {
const encoding = opts.encoding ?? 'utf8'
const res = spawnSync(file, args, {
cwd: opts.cwd,
env: opts.env,
maxBuffer: opts.maxBuffer || 4 * 1024 * 1024,
})
if (res.error) throw res.error
const stdout = decode(res.stdout, encoding)
const stderr = decode(res.stderr, encoding)
if (res.status && res.status !== 0) {
const err = new Error(`Command failed: ${file} ${args.join(' ')}`)
err.status = res.status
err.code = res.status
err.signal = res.signal
err.stdout = stdout
err.stderr = stderr
throw err
}
return { stdout, stderr, status: res.status ?? 0 }
}