From b4b2118933d5fcfa1de5f6d53d17535793b0b36f Mon Sep 17 00:00:00 2001 From: Adi <6841988+DeepSpace2@users.noreply.github.com> Date: Fri, 15 May 2026 17:18:03 +0300 Subject: [PATCH 1/2] feat: add cpu limit to docker, podman and lxd containers --- glances/plugins/containers/__init__.py | 4 +++ glances/plugins/containers/engines/docker.py | 17 +++++++++-- glances/plugins/containers/engines/lxd.py | 13 ++++++++ glances/plugins/containers/engines/podman.py | 22 ++++++++++++++ tests/test_plugin_lxd.py | 32 +++++++++++++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/glances/plugins/containers/__init__.py b/glances/plugins/containers/__init__.py index 9cd61c72..51176ac0 100644 --- a/glances/plugins/containers/__init__.py +++ b/glances/plugins/containers/__init__.py @@ -52,6 +52,10 @@ fields_description = { 'description': 'Container CPU consumption', 'unit': 'percent', }, + 'cpu_limit': { + 'description': 'Container CPU limit', + 'unit': 'number', + }, 'memory_inactive_file': { 'description': 'Container memory inactive file', 'unit': 'byte', diff --git a/glances/plugins/containers/engines/docker.py b/glances/plugins/containers/engines/docker.py index 8fb2ccf1..7c6be50d 100644 --- a/glances/plugins/containers/engines/docker.py +++ b/glances/plugins/containers/engines/docker.py @@ -86,9 +86,9 @@ class DockerStatsFetcher: def _get_cpu_stats(self) -> dict[str, float] | None: """Return the container CPU usage. - Output: a dict {'total': 1.49} + Output: a dict {'total': 1.49, 'limit': 2.0} """ - stats = {'total': 0.0} + stats = {'total': 0.0, 'limit': 0.0} try: cpu_stats = self._streamer.stats['cpu_stats'] @@ -114,6 +114,18 @@ class DockerStatsFetcher: self._log_debug("Can't compute CPU usage", e) return None + host_config = self._container.attrs.get('HostConfig', {}) + nano_cpus = host_config.get('NanoCpus', 0) + if nano_cpus > 0: + stats['limit'] = nano_cpus / 1e9 + else: + cpu_quota = host_config.get('CpuQuota', 0) + cpu_period = host_config.get('CpuPeriod', 100_000) + if cpu_quota > 0 and cpu_period > 0: + stats['limit'] = cpu_quota / cpu_period + else: + stats['limit'] = float(cpu.get('nb_core', 0)) + # Return the stats return stats @@ -342,6 +354,7 @@ class DockerExtension: # Additional fields stats['cpu_percent'] = stats['cpu']['total'] + stats['cpu_limit'] = stats['cpu'].get('limit') stats['memory_usage'] = stats['memory'].get('usage') if stats['memory'].get('cache') is not None: stats['memory_usage'] -= stats['memory']['cache'] diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index 0da3b0ad..26338220 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -12,6 +12,8 @@ import time from datetime import datetime from typing import Any +import psutil + from glances.globals import nativestr, pretty_date from glances.logger import logger @@ -107,6 +109,16 @@ class LxdStatsFetcher: stats["total"] = 0.0 except (KeyError, TypeError, ZeroDivisionError) as e: logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab CPU stats ({e})") + + try: + limit = self._instance.expanded_config.get('limits.cpu') + if limit: + stats['limit'] = float(limit) + else: + stats['limit'] = float(psutil.cpu_count()) + except (ValueError, TypeError): + stats['limit'] = float(psutil.cpu_count()) + return stats def _get_memory_stats(self, state) -> dict[str, Any]: @@ -305,6 +317,7 @@ class LxdExtension: # Additional fields stats['cpu_percent'] = stats['cpu'].get('total') + stats['cpu_limit'] = stats['cpu'].get('limit') stats['memory_usage'] = stats['memory'].get('usage') stats['memory_inactive_file'] = stats['memory'].get('inactive_file') stats['memory_limit'] = stats['memory'].get('limit') diff --git a/glances/plugins/containers/engines/podman.py b/glances/plugins/containers/engines/podman.py index e9cfbb7d..86ccc7a6 100644 --- a/glances/plugins/containers/engines/podman.py +++ b/glances/plugins/containers/engines/podman.py @@ -11,6 +11,8 @@ import time from datetime import datetime from typing import Any +import psutil + from glances.globals import nativestr, pretty_date, replace_special_chars, string_value_to_float from glances.logger import logger from glances.stats_streamer import ThreadedIterableStreamer @@ -33,6 +35,12 @@ class PodmanContainerStatsFetcher: def __init__(self, container): self._container = container + # Full inspection to get limits (see issue #1152: List API doesn't include HostConfig) + try: + self._inspect_data = self._container.inspect() + except Exception: + self._inspect_data = {} + # Previous stats are stored in the self._old_computed_stats variable # We store time data to enable rate calculations to avoid complexity for consumers of the APIs exposed. self._old_computed_stats = {} @@ -85,6 +93,19 @@ class PodmanContainerStatsFetcher: try: stats["cpu"]["total"] = api_stats["CPU"] + # CPU limit + host_config = self._inspect_data.get('HostConfig') + nano_cpus = host_config.get('NanoCpus', 0) + if nano_cpus > 0: + stats['cpu']['limit'] = nano_cpus / 1e9 + else: + cpu_quota = host_config.get('CpuQuota', 0) + cpu_period = host_config.get('CpuPeriod', 0) + if cpu_quota > 0 and cpu_period > 0: + stats['cpu']['limit'] = cpu_quota / cpu_period + else: + # Fallback to the number of available cores + stats['cpu']['limit'] = float(psutil.cpu_count()) stats["memory"]["usage"] = api_stats["MemUsage"] stats["memory"]["limit"] = api_stats["MemLimit"] @@ -384,6 +405,7 @@ class PodmanExtension: # Additional fields stats["cpu_percent"] = stats["cpu"].get("total") + stats["cpu_limit"] = stats["cpu"].get("limit") stats["memory_usage"] = stats["memory"].get("usage") if stats["memory"].get("cache") is not None: stats["memory_usage"] -= stats["memory"]["cache"] diff --git a/tests/test_plugin_lxd.py b/tests/test_plugin_lxd.py index da241535..57764557 100755 --- a/tests/test_plugin_lxd.py +++ b/tests/test_plugin_lxd.py @@ -47,6 +47,7 @@ def make_mock_instance( name="test-container", status="Running", config=None, + expanded_config=None, expanded_devices=None, created_at="2026-01-01T00:00:00Z", last_used_at="2026-03-15T10:00:00Z", @@ -57,6 +58,7 @@ def make_mock_instance( instance.name = name instance.status = status instance.config = config or {"image.description": "Ubuntu 24.04 LTS"} + instance.expanded_config = expanded_config or {} instance.expanded_devices = expanded_devices or {} instance.created_at = created_at instance.last_used_at = last_used_at @@ -154,6 +156,33 @@ class TestLxdStatsFetcher: # total=0 means unlimited, should fall back to usage_peak assert stats["memory"]["limit"] == 4_000_000_000 + def test_cpu_limit_explicit(self): + from glances.plugins.containers.engines.lxd import LxdStatsFetcher + + instance = make_mock_instance(expanded_config={'limits.cpu': '4'}) + instance.state = lambda: make_mock_state() + + fetcher = LxdStatsFetcher(instance, poll_interval=999) + time.sleep(0.2) + stats = fetcher.activity_stats + fetcher.stop() + + assert stats["cpu"]["limit"] == 4.0 + + def test_cpu_limit_fallback(self): + from glances.plugins.containers.engines.lxd import LxdStatsFetcher + + instance = make_mock_instance(expanded_config={}) + instance.state = lambda: make_mock_state() + + with patch('psutil.cpu_count', return_value=8): + fetcher = LxdStatsFetcher(instance, poll_interval=999) + time.sleep(0.2) + stats = fetcher.activity_stats + fetcher.stop() + + assert stats["cpu"]["limit"] == 8.0 + def test_stop_terminates_thread(self, mock_instance): from glances.plugins.containers.engines.lxd import LxdStatsFetcher @@ -193,7 +222,7 @@ class TestLxdExtensionGenerateStats: fetcher = MagicMock() fetcher.activity_stats = { - "cpu": {"total": 50.0}, + "cpu": {"total": 50.0, "limit": 2.0}, "memory": {"usage": 1_000_000_000, "limit": 2_000_000_000, "inactive_file": 0}, "io": {"ior": 100, "iow": 50, "time_since_update": 2}, "network": {"rx": 5000, "tx": 3000, "time_since_update": 2}, @@ -203,6 +232,7 @@ class TestLxdExtensionGenerateStats: stats = ext.generate_stats(instance) assert stats['cpu_percent'] == 50.0 + assert stats['cpu_limit'] == 2.0 assert stats['memory_usage'] == 1_000_000_000 assert stats['memory_limit'] == 2_000_000_000 assert stats['io_rx'] == 50 From b42defb1d8d7396bf69abbcb826eb6d045d8b5b2 Mon Sep 17 00:00:00 2001 From: Yan Date: Fri, 18 Jul 2025 09:33:54 +0000 Subject: [PATCH 2/2] Keep auto_unit within limits, so columns stay aligned Occasionally, columns got misaligned, because auto_unit returned too many decimals when the number was slightly below 10 or 100. Actually, when (9.995 <= n < 10) and (99.95 < n < 100). For example, 10*2**20-1 returned 10.00M instead of 10.0M and 100*2**20-1 returned 100.0M instead of 100M. Tests added to verify correctness. --- glances/globals.py | 4 ++-- tests/test_core.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/glances/globals.py b/glances/globals.py index de316bdf..8255088b 100644 --- a/glances/globals.py +++ b/glances/globals.py @@ -470,9 +470,9 @@ def auto_unit(number, low_precision=False, min_symbol='K', none_symbol='-'): value = float(number) / prefix[symbol] if value > 1: decimal_precision = 0 - if value < 10: + if value <= 9.995: decimal_precision = 2 - elif value < 100: + elif value < 99.95: decimal_precision = 1 if low_precision: if symbol in 'MK': diff --git a/tests/test_core.py b/tests/test_core.py index e207bad0..38aee2e5 100755 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -779,6 +779,30 @@ class TestGlances(unittest.TestCase): self.assertEqual(auto_unit(1073741824, low_precision=True), '1024M') self.assertEqual(auto_unit(1181116006), '1.10G') self.assertEqual(auto_unit(1181116006, low_precision=True), '1.1G') + # Edge cases: + m = 2**20 + g = 2**30 + self.assertEqual(auto_unit(9.9 * m), '9.90M') + self.assertEqual(auto_unit(9.99 * m), '9.99M') + self.assertEqual(auto_unit(9.991 * m), '9.99M') + self.assertEqual(auto_unit(9.994 * m), '9.99M') + self.assertEqual(auto_unit(9.995 * m), '9.99M') + self.assertEqual(auto_unit(10 * m), '10.0M') + self.assertEqual(auto_unit(99 * m), '99.0M') + self.assertEqual(auto_unit(99.9 * m), '99.9M') + self.assertEqual(auto_unit(99.91 * m), '99.9M') + self.assertEqual(auto_unit(99.94 * m), '99.9M') + self.assertEqual(auto_unit(100 * m), '100M') + # Special cases that used to overflow and misalign columns + self.assertEqual(auto_unit(9.996 * m), '10.0M') # was 10.00M + self.assertEqual(auto_unit(9.999 * m), '10.0M') # was 10.00M + self.assertEqual(auto_unit(99.95 * m), '100M') # was 100.0M + self.assertEqual(auto_unit(99.96 * m), '100M') # was 100.0M + self.assertEqual(auto_unit(99.99 * m), '100M') # was 100.0M + self.assertEqual(auto_unit(10 * m - 1), '10.0M') # was 10.00M + self.assertEqual(auto_unit(100 * m - 1), '100M') # was 100.0M + self.assertEqual(auto_unit(10 * g - 1), '10.0G') # was 10.00G + self.assertEqual(auto_unit(100 * g - 1), '100G') # was 100.0G def test_094_thresholds(self): """Test thresholds classes"""