diff --git a/agent/ec_agent.py b/agent/ec_agent.py index 3079a19..474dd34 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -32,7 +32,7 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.5" +VERSION = "1.4.6" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 LIBRARY_DOWNLOAD_WORKERS = 4 @@ -933,7 +933,7 @@ class Agent: def instance_resources(self): """One bounded Docker stats batch; unavailable samples are never zero load.""" - ids = self.docker.call("container", "ls", "--filter", LABEL + ".managed=true", + ids = self.docker.call("container", "ls", "--filter", "label=" + LABEL + ".managed=true", "--no-trunc", "--format", "{{.ID}}", timeout=10).splitlines()[:1000] if not ids: return [] @@ -1084,8 +1084,10 @@ class Agent: images.extend(alias for alias, image_id in json.loads(aliases.read_text()).items() if image_id in image_ids) resources = {} if docker_running: - with contextlib.suppress(AgentError, ValueError, TypeError, KeyError): + try: resources["instanceResources"] = self.instance_resources() + except (AgentError, ValueError, TypeError, KeyError): + resources["instanceResourcesError"] = "Container resource sampling failed. Check Docker access and stats availability on this host." os_name = platform.system() with contextlib.suppress(OSError): for line in Path("/etc/os-release").read_text().splitlines(): diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index dab0e0a..f15fc40 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -124,6 +124,7 @@ class AgentTests(unittest.TestCase): self.assertEqual(samples[0]["cpuCores"], 1.5) self.assertEqual(samples[0]["memoryBytes"], int(384.5 * 1024 ** 2)) self.assertGreater(samples[0]["uptimeSeconds"], 300) + self.assertEqual(call.call_args_list[0].args, ("container", "ls", "--filter", "label=io.electric-cherry.managed=true", "--no-trunc", "--format", "{{.ID}}")) self.assertEqual(call.call_args_list[-1].args, ("stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", name)) def test_instance_resources_ignores_stopped_foreign_and_mismatched_containers(self): @@ -149,7 +150,13 @@ class AgentTests(unittest.TestCase): with patch.object(self.docker, "call", side_effect=["container-id\n", json.dumps([self.resource_container()]), json.dumps(good)]): self.assertEqual(self.agent.instance_resources()[0]["cpuCores"], 0) with patch.object(self.agent, "instance_resources", side_effect=AgentError("timed out")): - self.assertNotIn("instanceResources", self.agent.inventory()) + inventory = self.agent.inventory() + self.assertNotIn("instanceResources", inventory) + self.assertIn("sampling failed", inventory["instanceResourcesError"]) + with patch.object(self.agent, "instance_resources", return_value=[]): + inventory = self.agent.inventory() + self.assertEqual(inventory["instanceResources"], []) + self.assertNotIn("instanceResourcesError", inventory) 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 0067f3a..676906b 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -140,12 +140,19 @@ memory measurements. Hosts with stale or missing measurements cannot accept new servers. Existing API clients may still explicitly constrain a launch to a host. Admission reserves a provisional host and port atomically, including collection -launches. Agent **1.4.3** samples each managed running container with a bounded +launches. Agent **1.4.6** fixes container discovery for resource sampling (the +earlier collector omitted `label=` from its Docker filter). It samples each +managed running container with a bounded [`docker stats`](https://docs.docker.com/reference/cli/docker/container/stats/) batch during inventory collection. CPU is normalized to cores (100% Docker CPU is one core); memory is the Linux working usage reported by Docker, excluding reclaimable cache. Missing or failed samples stay unknown instead of becoming zero usage. Samples must match the host's live instance and generation. +Measurements arrive through `/api/agent/heartbeat`, normally every 15 seconds +plus collection and delivery time, independently of RCON or inspection operations. +Sampling failures appear on the host's Learned server capacity panel. Existing +containers can begin contributing running samples as soon as the agent updates; +startup samples require observing the first five minutes of a container's run. The controller retains per-instance minute peaks for 24 hours and caches learned CPU/memory allowances. The first five minutes after a container starts count as diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index 2844e28..303fbcf 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -69,6 +69,7 @@ export interface HostInventory { capabilities?: string[]; agentUpdate?: HostAgentUpdate; instanceResources?: InstanceResources[]; + instanceResourcesError?: string; } export interface InstanceResources { instanceId: string; generation: number; uptimeSeconds: number; cpuCores: number; memoryBytes: number; diff --git a/src/lib/server/hosts/calibration.ts b/src/lib/server/hosts/calibration.ts index c2726c0..ef948c8 100644 --- a/src/lib/server/hosts/calibration.ts +++ b/src/lib/server/hosts/calibration.ts @@ -15,6 +15,8 @@ interface AllowanceRow { function database() { const handle = db(); ensureHostSchema(handle); return handle; } export function validateInstanceResources(inventory: HostInventory): void { + if (inventory.instanceResourcesError !== undefined && (typeof inventory.instanceResourcesError !== 'string' + || inventory.instanceResourcesError.length > 500)) throw new Error('Invalid instance resource sampling error.'); const samples = inventory.instanceResources; if (samples === undefined) return; if (!Array.isArray(samples) || samples.length > 1000) throw new Error('Invalid instance resource samples.'); diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index ade8eb5..c3e0a71 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -62,7 +62,8 @@
Learning begins as agent 1.4.3 or later reports running containers. Temporary allowances stay in use until enough measurements are available.
{/if} + {#if host.inventory.instanceResourcesError}{host.inventory.instanceResourcesError}
{/if} + {#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.
{/if} {/if}