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 urllib.request
import uuid import uuid
VERSION = "1.4.1" VERSION = "1.4.2"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -710,6 +710,8 @@ class Agent:
self.docker = docker or Docker() self.docker = docker or Docker()
self.stopping = threading.Event() self.stopping = threading.Event()
self.current_operation = None self.current_operation = None
self.cpu_sample_lock = threading.Lock()
self.cpu_sample = None
self.instance_locks = {} self.instance_locks = {}
self.read_gate = ReadWriteGate() self.read_gate = ReadWriteGate()
self.read_claim_lock = threading.Lock() self.read_claim_lock = threading.Lock()
@@ -899,7 +901,36 @@ class Agent:
except (OSError, ValueError): except (OSError, ValueError):
raise AgentError("Controller request failed") from None 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): def inventory(self):
cpu_usage = self.cpu_usage_percent()
usage = shutil.disk_usage(self.root) usage = shutil.disk_usage(self.root)
memory = {} memory = {}
try: try:
@@ -1004,7 +1035,7 @@ class Agent:
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) 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): def safe_to_disrupt(self, instance_id, generation):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
+34 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import tempfile import tempfile
import threading import threading
import unittest 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") spec = importlib.util.spec_from_file_location("ec_agent", Path(__file__).resolve().parents[1] / "ec_agent.py")
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
@@ -74,6 +74,39 @@ class AgentTests(unittest.TestCase):
self.agent.journal.close() self.agent.journal.close()
self.temp.cleanup() 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): 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 {}} 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 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. 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 When publishing changed agent code, bump its numeric `VERSION` in
`agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest `agent/ec_agent.py` and rebuild the controller. Packaging derives the manifest
version and checksums from the sources; an already installed version is never 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 { export interface HostInventory {
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string; 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; storageTotal?: number; storageAvailable?: number; dockerVersion?: string; dockerRunning?: boolean;
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[]; directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
currentOperation?: string | null; [key: string]: unknown; 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']) { 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[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}.`); 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.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.'); 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> </section>
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if} {#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
<div class="grid"><section class="panel"><h2>Inventory</h2><dl> <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}> </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> <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> </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');
});
});