|
| 1 | +import os from "node:os"; |
| 2 | +import fs from "node:fs/promises"; |
| 3 | +import { env, createLogger } from "@sourcebot/shared"; |
| 4 | +import { SystemInfo } from "./types"; |
| 5 | + |
| 6 | +const logger = createLogger('system-info'); |
| 7 | + |
| 8 | +// cgroup v2 (unified hierarchy). |
| 9 | +const CGROUP_V2_MEMORY_MAX = '/sys/fs/cgroup/memory.max'; |
| 10 | +const CGROUP_V2_MEMORY_CURRENT = '/sys/fs/cgroup/memory.current'; |
| 11 | +const CGROUP_V2_CPU_MAX = '/sys/fs/cgroup/cpu.max'; |
| 12 | + |
| 13 | +// cgroup v1 (legacy hierarchy). |
| 14 | +const CGROUP_V1_MEMORY_LIMIT = '/sys/fs/cgroup/memory/memory.limit_in_bytes'; |
| 15 | +const CGROUP_V1_MEMORY_USAGE = '/sys/fs/cgroup/memory/memory.usage_in_bytes'; |
| 16 | +const CGROUP_V1_CPU_QUOTA = '/sys/fs/cgroup/cpu/cpu.cfs_quota_us'; |
| 17 | +const CGROUP_V1_CPU_PERIOD = '/sys/fs/cgroup/cpu/cpu.cfs_period_us'; |
| 18 | + |
| 19 | +/** |
| 20 | + * Collects a snapshot of the resources this deployment is running with, so we |
| 21 | + * can quickly diagnose resource issues (e.g. "the customer doesn't have enough |
| 22 | + * RAM") from the service ping. |
| 23 | + * |
| 24 | + * Everything is best-effort: any value we can't read is reported as `null` so a |
| 25 | + * partial snapshot still goes out. Resource figures come from the container's |
| 26 | + * cgroup (and `statfs` for disk) rather than host-level readings, which in a |
| 27 | + * Dockerized or Kubernetes deployment report the underlying machine, not the |
| 28 | + * container's actual limits. |
| 29 | + */ |
| 30 | +export const getSystemInfo = async (): Promise<SystemInfo> => { |
| 31 | + const [memoryLimitBytes, memoryUsedBytes, cpuQuota, disk] = await Promise.all([ |
| 32 | + readCgroupMemoryLimit(), |
| 33 | + readCgroupMemoryUsage(), |
| 34 | + readCgroupCpuQuota(), |
| 35 | + readDiskUsage(env.DATA_CACHE_DIR), |
| 36 | + ]); |
| 37 | + |
| 38 | + return { |
| 39 | + platform: os.platform(), |
| 40 | + arch: os.arch(), |
| 41 | + cpuQuota, |
| 42 | + memoryLimitMiB: memoryLimitBytes === null ? null : bytesToMiB(memoryLimitBytes), |
| 43 | + memoryUsedMiB: memoryUsedBytes === null ? null : bytesToMiB(memoryUsedBytes), |
| 44 | + diskTotalMiB: disk.totalBytes === null ? null : bytesToMiB(disk.totalBytes), |
| 45 | + diskFreeMiB: disk.freeBytes === null ? null : bytesToMiB(disk.freeBytes), |
| 46 | + }; |
| 47 | +}; |
| 48 | + |
| 49 | +const BYTES_PER_MIB = 1024 * 1024; |
| 50 | + |
| 51 | +// Memory and disk are both reported in whole MiB (binary, 1024-based units), |
| 52 | +// matching how RAM and container/k8s volumes are sized. |
| 53 | +const bytesToMiB = (bytes: number): number => Math.round(bytes / BYTES_PER_MIB); |
| 54 | + |
| 55 | +const readFileTrimmed = async (path: string): Promise<string | null> => { |
| 56 | + try { |
| 57 | + const contents = await fs.readFile(path, 'utf8'); |
| 58 | + return contents.trim(); |
| 59 | + } catch { |
| 60 | + // The file won't exist outside of a cgroup-managed environment (e.g. |
| 61 | + // local dev on macOS), which is expected — treat as "unknown". |
| 62 | + return null; |
| 63 | + } |
| 64 | +}; |
| 65 | + |
| 66 | +/** |
| 67 | + * Parses a byte count from a cgroup file, returning `null` for "unlimited": |
| 68 | + * the literal `max` in cgroup v2, or a sentinel larger than JS can safely |
| 69 | + * represent in cgroup v1 (e.g. 9223372036854771712). |
| 70 | + */ |
| 71 | +const parseCgroupBytes = (raw: string | null): number | null => { |
| 72 | + if (raw === null || raw === 'max') { |
| 73 | + return null; |
| 74 | + } |
| 75 | + const value = Number(raw); |
| 76 | + if (!Number.isSafeInteger(value) || value < 0) { |
| 77 | + return null; |
| 78 | + } |
| 79 | + return value; |
| 80 | +}; |
| 81 | + |
| 82 | +const readCgroupMemoryLimit = async (): Promise<number | null> => { |
| 83 | + const v2 = parseCgroupBytes(await readFileTrimmed(CGROUP_V2_MEMORY_MAX)); |
| 84 | + if (v2 !== null) { |
| 85 | + return v2; |
| 86 | + } |
| 87 | + return parseCgroupBytes(await readFileTrimmed(CGROUP_V1_MEMORY_LIMIT)); |
| 88 | +}; |
| 89 | + |
| 90 | +const readCgroupMemoryUsage = async (): Promise<number | null> => { |
| 91 | + const v2 = parseCgroupBytes(await readFileTrimmed(CGROUP_V2_MEMORY_CURRENT)); |
| 92 | + if (v2 !== null) { |
| 93 | + return v2; |
| 94 | + } |
| 95 | + return parseCgroupBytes(await readFileTrimmed(CGROUP_V1_MEMORY_USAGE)); |
| 96 | +}; |
| 97 | + |
| 98 | +/** |
| 99 | + * Returns the effective CPU limit in cores (e.g. 0.5, 2), or `null` when there |
| 100 | + * is no quota (unlimited) or it can't be read. |
| 101 | + */ |
| 102 | +const readCgroupCpuQuota = async (): Promise<number | null> => { |
| 103 | + // cgroup v2: `cpu.max` is "<quota> <period>" (in microseconds), or |
| 104 | + // "max <period>" when unlimited. |
| 105 | + const v2 = await readFileTrimmed(CGROUP_V2_CPU_MAX); |
| 106 | + if (v2 !== null) { |
| 107 | + const [quotaRaw, periodRaw] = v2.split(/\s+/); |
| 108 | + if (quotaRaw === 'max') { |
| 109 | + return null; |
| 110 | + } |
| 111 | + const quota = Number(quotaRaw); |
| 112 | + const period = Number(periodRaw); |
| 113 | + if (Number.isFinite(quota) && quota > 0 && Number.isFinite(period) && period > 0) { |
| 114 | + return roundToTwo(quota / period); |
| 115 | + } |
| 116 | + return null; |
| 117 | + } |
| 118 | + |
| 119 | + // cgroup v1: cpu.cfs_quota_us / cpu.cfs_period_us; a quota of -1 is unlimited. |
| 120 | + const quota = Number(await readFileTrimmed(CGROUP_V1_CPU_QUOTA)); |
| 121 | + const period = Number(await readFileTrimmed(CGROUP_V1_CPU_PERIOD)); |
| 122 | + if (!Number.isFinite(quota) || quota <= 0 || !Number.isFinite(period) || period <= 0) { |
| 123 | + return null; |
| 124 | + } |
| 125 | + return roundToTwo(quota / period); |
| 126 | +}; |
| 127 | + |
| 128 | +const readDiskUsage = async (path: string): Promise<{ totalBytes: number | null; freeBytes: number | null }> => { |
| 129 | + try { |
| 130 | + const stats = await fs.statfs(path); |
| 131 | + return { |
| 132 | + totalBytes: stats.blocks * stats.bsize, |
| 133 | + // bavail is blocks available to unprivileged users, which best |
| 134 | + // reflects what the deployment can actually write. |
| 135 | + freeBytes: stats.bavail * stats.bsize, |
| 136 | + }; |
| 137 | + } catch (error) { |
| 138 | + logger.debug(`Failed to read disk usage for ${path}: ${error}`); |
| 139 | + return { totalBytes: null, freeBytes: null }; |
| 140 | + } |
| 141 | +}; |
| 142 | + |
| 143 | +const roundToTwo = (n: number): number => Math.round(n * 100) / 100; |
0 commit comments