From a1b957392f7fa29b3664244d8733802945fc09b4 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 4 Oct 2026 20:30:43 -0400 Subject: [PATCH] Add CPU usage to host inventory --- agent/ec_agent.py | 35 +++++++++++++++++++++++++-- agent/tests/test_agent.py | 35 ++++++++++++++++++++++++++- docs/native-hosts.md | 7 ++++++ src/lib/hosts.ts | 2 +- src/lib/server/hosts/index.ts | 1 + src/routes/hosts/[id]/+page.svelte | 2 +- tests/unit/host-cpu-inventory.test.ts | 14 +++++++++++ 7 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/unit/host-cpu-inventory.test.ts diff --git a/agent/ec_agent.py b/agent/ec_agent.py index 5152831..57a00e6 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -30,7 +30,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.1" +VERSION = "1.4.2" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 LIBRARY_DOWNLOAD_WORKERS = 4 @@ -710,6 +710,8 @@ class Agent: self.docker = docker or Docker() self.stopping = threading.Event() self.current_operation = None + self.cpu_sample_lock = threading.Lock() + self.cpu_sample = None self.instance_locks = {} self.read_gate = ReadWriteGate() self.read_claim_lock = threading.Lock() @@ -899,7 +901,36 @@ class Agent: except (OSError, ValueError): raise AgentError("Controller request failed") from None + def cpu_usage_percent(self): + """Host-wide busy time since the previous inventory sample, without waiting.""" + with self.cpu_sample_lock: + previous = self.cpu_sample + self.cpu_sample = None + try: + with Path("/proc/stat").open() as source: + fields = source.readline().split() + if not fields or fields[0] != "cpu" or len(fields) < 9: + return None + # Guest time is already included in user/nice. Idle and I/O + # wait are not busy; steal counts as unavailable CPU capacity. + counters = tuple(int(value) for value in fields[1:9]) + if any(value < 0 for value in counters): + return None + except (OSError, ValueError): + return None + self.cpu_sample = counters + if previous is None: + return None + deltas = [current - old for current, old in zip(counters, previous)] + total = sum(deltas) + # Counters can reset and Linux can revise I/O wait downwards. + # Rebase rather than reporting a misleading utilization value. + if total <= 0 or any(delta < 0 for delta in deltas): + return None + return round(100 * (total - deltas[3] - deltas[4]) / total, 1) + def inventory(self): + cpu_usage = self.cpu_usage_percent() usage = shutil.disk_usage(self.root) memory = {} try: @@ -1004,7 +1035,7 @@ class Agent: os_name = line.split("=", 1)[1].strip('"') update = self.update_inventory() capabilities = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) - return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} + return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} def safe_to_disrupt(self, instance_id, generation): health = self.inspect(instance_id, generation) diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 5c2b4ff..01bc235 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -6,7 +6,7 @@ from pathlib import Path import tempfile import threading import unittest -from unittest.mock import patch +from unittest.mock import mock_open, patch spec = importlib.util.spec_from_file_location("ec_agent", Path(__file__).resolve().parents[1] / "ec_agent.py") module = importlib.util.module_from_spec(spec) @@ -74,6 +74,39 @@ class AgentTests(unittest.TestCase): self.agent.journal.close() self.temp.cleanup() + def cpu_sample(self, line): + with patch.object(Path, "open", mock_open(read_data=line)): + return self.agent.cpu_usage_percent() + + def test_cpu_usage_uses_interval_and_excludes_guest_double_counting(self): + self.assertIsNone(self.cpu_sample("cpu 100 0 50 800 50 0 0 0 20 0\n")) + # 30 busy ticks (including steal), 60 idle, 10 I/O wait = 30%. + self.assertEqual(self.cpu_sample("cpu 120 0 55 860 60 0 0 5 30 0\n"), 30.0) + self.assertEqual(self.cpu_sample("cpu 120 0 55 960 60 0 0 5 30 0\n"), 0.0) + self.assertEqual(self.cpu_sample("cpu 220 0 55 960 60 0 0 5 30 0\n"), 100.0) + + def test_cpu_usage_rebases_after_reset_or_invalid_sample(self): + baseline = "cpu 100 0 50 800 50 0 0 0\n" + self.assertIsNone(self.cpu_sample(baseline)) + self.assertIsNone(self.cpu_sample(baseline)) + self.assertIsNone(self.cpu_sample("cpu 10 0 5 80 5 0 0 0\n")) + self.assertIsNone(self.cpu_sample("cpu 20 0 5 90 4 0 0 0\n")) + for invalid in ("", "cpu0 1 2 3 4 5 6 7 8", "cpu 1 2", "cpu x 0 0 0 0 0 0 0", "cpu -1 0 0 0 0 0 0 0"): + self.assertIsNone(self.cpu_sample(invalid)) + self.assertIsNone(self.cpu_sample(baseline)) + with patch.object(Path, "open", side_effect=OSError("unavailable")): + self.assertIsNone(self.agent.cpu_usage_percent()) + self.assertIsNone(self.cpu_sample(baseline)) + + def test_inventory_reports_cpu_only_when_available(self): + for usage in (None, 0.0, 42.3, 100.0): + with patch.object(self.agent, "cpu_usage_percent", return_value=usage): + inventory = self.agent.inventory() + if usage is None: + self.assertNotIn("cpuUsagePercent", inventory) + else: + self.assertEqual(inventory["cpuUsagePercent"], usage) + def operation(self, kind, payload=None, op_id="operation-1", generation=1): return {"id": op_id, "kind": kind, "instanceId": INSTANCE, "generation": generation, "payload": payload or {}} diff --git a/docs/native-hosts.md b/docs/native-hosts.md index a906db4..95e9793 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -116,6 +116,13 @@ shows **Needs attention**. The updater itself is a stable bootstrap component: it changes only when the installer is rerun. If a future release requires an unsupported updater protocol, rerun that release's installer on the host. +The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures +host-wide busy time across all cores on a 0–100% scale between inventory samples +(normally about 15 seconds). Idle and I/O wait are excluded; stolen CPU time +counts as unavailable capacity. The first sample after startup, unavailable +counters, and older agents show **Not reported**. Offline hosts retain their +last reported value. This is current inventory, not historical metrics. + When publishing changed agent code, bump its numeric `VERSION` in `agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest version and checksums from the sources; an already installed version is never diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index 4c607a6..d913217 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -62,7 +62,7 @@ export function hostAgentUpdateMessage(inventory: HostInventory): string { export interface HostInventory { hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string; - cpu?: string; cpuCount?: number; memoryTotal?: number; memoryAvailable?: number; + cpu?: string; cpuCount?: number; cpuUsagePercent?: number; memoryTotal?: number; memoryAvailable?: number; storageTotal?: number; storageAvailable?: number; dockerVersion?: string; dockerRunning?: boolean; directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[]; currentOperation?: string | null; [key: string]: unknown; diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index af60590..460bad8 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -218,6 +218,7 @@ export function validateInventory(value: unknown): HostInventory { for (const key of ['cpuCount','memoryTotal','memoryAvailable','storageTotal','storageAvailable','protocolVersion']) { if (inventory[key] !== undefined && (typeof inventory[key] !== 'number' || !Number.isSafeInteger(inventory[key]) || Number(inventory[key]) < 0)) throw new Error(`Invalid inventory ${key}.`); } + if (inventory.cpuUsagePercent !== undefined && (typeof inventory.cpuUsagePercent !== 'number' || !Number.isFinite(inventory.cpuUsagePercent) || inventory.cpuUsagePercent < 0 || inventory.cpuUsagePercent > 100)) throw new Error('Invalid inventory cpuUsagePercent.'); for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`); if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.'); if (inventory.capabilities !== undefined && (!Array.isArray(inventory.capabilities) || inventory.capabilities.length > 32 || inventory.capabilities.some(value => typeof value !== 'string' || value.length > 80))) throw new Error('Invalid agent capability inventory.'); diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index 1ac2c83..c5cfc8c 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -56,7 +56,7 @@ {#if !cleaning}

Automatic host setup

{/if}

Inventory

-
Hostname
{host.hostname || 'Pending'}
Agent
{host.inventory.agentVersion ?? 'Unknown'}
Platform
{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}
CPU
{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)
Memory available / total
{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}
Game storage available / total
{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}
Docker
{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}
Current operation
{host.inventory.currentOperation ?? 'None reported'}
+
Hostname
{host.hostname || 'Pending'}
Agent
{host.inventory.agentVersion ?? 'Unknown'}
Platform
{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}
CPU
{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)
CPU usage
{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}
Memory available / total
{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}
Game storage available / total
{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}
Docker
{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}
Current operation
{host.inventory.currentOperation ?? 'None reported'}

Host configuration

{#if canManage}
{:else}
Friendly name
{host.name}
Game ports
{host.portMin}–{host.portMax}
Local bind address
{host.bindAddress}
Public / NAT address
{host.publicAddress || 'Not configured'}
Minimum free storage
{bytes(host.minimumFreeBytes)}

Host configuration is managed by administrators.

{/if}

Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.

diff --git a/tests/unit/host-cpu-inventory.test.ts b/tests/unit/host-cpu-inventory.test.ts new file mode 100644 index 0000000..911bc01 --- /dev/null +++ b/tests/unit/host-cpu-inventory.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { validateInventory } from '../../src/lib/server/hosts/index.js'; + +describe('host CPU inventory', () => { + it('accepts older agents without CPU usage', () => { + expect(validateInventory({ cpuCount: 4 }).cpuUsagePercent).toBeUndefined(); + }); + it.each([0, 0.1, 42.3, 100])('accepts CPU usage %s', cpuUsagePercent => { + expect(validateInventory({ cpuUsagePercent }).cpuUsagePercent).toBe(cpuUsagePercent); + }); + it.each([-1, 100.1, NaN, Infinity, -Infinity, null, '25', true, {}])('rejects invalid CPU usage %j', cpuUsagePercent => { + expect(() => validateInventory({ cpuUsagePercent })).toThrow('Invalid inventory cpuUsagePercent'); + }); +});