/** * PearDock bridge collector (Phase 3 spike). * * Dials dock / agent peers and remaps their docker/container charts into * peardock.* — dials remote peers over public RPC (no PearDock source vendored). * * Enable: PEARDATA_PEARDOCK=1 * Peers: PEARDATA_PEARDOCK_PEERS=hex,hex (or PEARDATA_PEARDOCK_PEERS_FILE) * Optional: PEARDATA_PEARDOCK_SEED, PEARDATA_PEARDOCK_POLL_MS * * Charts: peardock.containers, peardock.cpu., peardock.mem. * (sourced from remote docker.* charts when present) */ import fs from 'fs' import { EventEmitter } from 'events' import { PearDataConnection } from '../../../client/connection.js' import { Methods } from '../../../shared/protocol.js' import { registerChart } from '../../../shared/metrics.js' import logger from '../../utils/logger.js' const log = logger.child('peardock') export function isPearDockEnabled() { const v = process.env.PEARDATA_PEARDOCK return v === '1' || v === 'on' || v === 'true' } /** * @returns {string[]} */ export function parsePearDockPeers() { const raw = process.env.PEARDATA_PEARDOCK_PEERS || '' const fromEnv = raw .split(/[,\s]+/) .map((s) => s.trim().toLowerCase()) .filter((s) => /^[0-9a-f]{64}$/.test(s)) const file = process.env.PEARDATA_PEARDOCK_PEERS_FILE if (!file) return [...new Set(fromEnv)] try { const text = fs.readFileSync(file, 'utf8') const fromFile = text .split(/\r?\n/) .map((l) => l.replace(/#.*$/, '').trim().toLowerCase()) .filter((s) => /^[0-9a-f]{64}$/.test(s)) return [...new Set([...fromEnv, ...fromFile])] } catch { return [...new Set(fromEnv)] } } /** * Map remote docker/container chart id → peardock chart id. * @param {string} chartId * @returns {string|null} */ export function remapDockChart(chartId) { const id = String(chartId || '') if (id.startsWith('docker.')) return `peardock.${id.slice('docker.'.length)}` if (id.startsWith('container.')) return `peardock.${id.slice('container.'.length)}` if (id.startsWith('cgroup.docker.')) return `peardock.${id.slice('cgroup.docker.'.length)}` return null } /** * @param {any} metrics getAllMetrics-style payload * @returns {Array<{ chart: string, context: string, values: Record, sourceChart: string }>} */ export function extractDockCharts(metrics) { const root = metrics?.body || metrics const charts = root?.charts || {} /** @type {Array<{ chart: string, context: string, values: Record, sourceChart: string }>} */ const out = [] for (const [chartId, point] of Object.entries(charts)) { const mapped = remapDockChart(chartId) if (!mapped) continue const values = point?.dimensions || point?.values || {} /** @type {Record} */ const nums = {} for (const [k, v] of Object.entries(values)) { const n = typeof v === 'object' && v != null ? Number(v.value) : Number(v) nums[k] = Number.isFinite(n) ? n : null } const parts = mapped.split('.') const context = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : mapped out.push({ chart: mapped, context, values: nums, sourceChart: chartId, }) } return out } function registerMappedChart(mappedId, context, values) { const dims = Object.keys(values).map((id) => ({ id, name: id, algorithm: 'absolute', })) if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' }) registerChart({ id: mappedId, name: mappedId, context, title: mappedId, units: context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count', family: 'peardock', chartType: 'line', priority: 8500, plugin: 'peardock', dimensions: dims, }) } export class PearDockCollector extends EventEmitter { constructor(opts = {}) { super() this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PEARDOCK_POLL_MS) || 5000 this.adminSeed = opts.adminSeed || process.env.PEARDATA_PEARDOCK_SEED || null this.peerKeys = opts.peers || parsePearDockPeers() /** @type {Map} */ this.conns = new Map() this._timer = null this._busy = false } start() { if (this._timer) return registerChart({ id: 'peardock.containers', name: 'peardock.containers', context: 'peardock.containers', title: 'PearDock bridged containers', units: 'containers', family: 'peardock', chartType: 'line', priority: 8490, plugin: 'peardock', dimensions: [ { id: 'charts', name: 'charts', algorithm: 'absolute' }, { id: 'peers', name: 'peers', algorithm: 'absolute' }, ], }) log.info('PearDock bridge started', { peers: this.peerKeys.length, pollMs: this.pollMs }) this._tick() this._timer = setInterval(() => this._tick(), this.pollMs) if (typeof this._timer.unref === 'function') this._timer.unref() } stop() { if (this._timer) { clearInterval(this._timer) this._timer = null } for (const [pk, conn] of this.conns) { if (conn) conn.destroy().catch(() => {}) this.conns.set(pk, null) } } async _ensure(pk) { let conn = this.conns.get(pk) if (conn?.connected) return conn try { if (conn) await conn.destroy().catch(() => {}) conn = new PearDataConnection(pk, { adminSeed: this.adminSeed, timeoutMs: 20_000, }) await conn.connect() this.conns.set(pk, conn) conn.on('disconnected', () => this.conns.set(pk, null)) return conn } catch (err) { this.conns.set(pk, null) log.warn('PearDock dial failed', { peer: pk.slice(0, 12), error: err.message }) return null } } async _tick() { if (this._busy) return this._busy = true try { const ts = Date.now() /** @type {Array<{ chart: string, context: string, ts: number, values: Record }>} */ const batch = [] let peersUp = 0 let chartCount = 0 for (const pk of this.peerKeys) { const conn = await this._ensure(pk) if (!conn) continue try { const metrics = await conn.request(Methods.getAllMetrics, { format: 'json' }) const mapped = extractDockCharts(metrics) peersUp++ for (const row of mapped) { registerMappedChart(row.chart, row.context, row.values) batch.push({ chart: row.chart, context: row.context, ts, values: row.values, }) chartCount++ } } catch (err) { log.warn('PearDock poll failed', { peer: pk.slice(0, 12), error: err.message }) try { await conn.destroy() } catch { // ignore } this.conns.set(pk, null) } } batch.unshift({ chart: 'peardock.containers', context: 'peardock.containers', ts, values: { charts: chartCount, peers: peersUp }, }) this.emit('samples', batch) } finally { this._busy = false } } } /** @type {PearDockCollector|null} */ let singleton = null export function getPearDockCollector() { if (!singleton) singleton = new PearDockCollector() return singleton }