Add CPU usage to host inventory

This commit is contained in:
2026-10-04 20:30:43 -04:00
parent 456af32b36
commit a1b957392f
7 changed files with 91 additions and 5 deletions
+33 -2
View File
@@ -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)
+34 -1
View File
@@ -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 {}}
+7
View File
@@ -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
+1 -1
View File
@@ -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;
+1
View File
@@ -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.');
+1 -1
View File
@@ -56,7 +56,7 @@
</section>
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
<div class="grid"><section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section></div>
+14
View File
@@ -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');
});
});