Skip to content

Commit 90776eb

Browse files
committed
feature: [ESXi] CPU measurement using esxtop
Introduce methods for measuring CPU usage with esxtop tool + unit tests Signed-off-by: szmijews <szymon.zmijewski@intel.com>
1 parent 9ca74d9 commit 90776eb

File tree

2 files changed

+165
-1
lines changed

2 files changed

+165
-1
lines changed

mfd_host/feature/cpu/esxi.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44

55
import logging
66
import re
7+
from time import sleep
8+
9+
from mfd_common_libs import add_logging_level, log_levels, TimeoutCounter
10+
from mfd_connect.process.rpyc import RPyCProcess
711

8-
from mfd_common_libs import add_logging_level, log_levels
912
from mfd_host.exceptions import CPUFeatureExecutionError, CPUFeatureException
1013
from mfd_host.feature.cpu.base import BaseFeatureCPU
1114
from mfd_network_adapter.data_structures import State
@@ -62,3 +65,73 @@ def set_numa_affinity(self, numa_state: State) -> None:
6265
f'esxcli system settings advanced set {value} -o "/Numa/LocalityWeightActionAffinity"',
6366
custom_exception=CPUFeatureExecutionError,
6467
)
68+
69+
def start_cpu_measurement(self) -> RPyCProcess:
70+
"""
71+
Start CPU measurement on SUT host.
72+
73+
:return: Handle to process
74+
"""
75+
logger.debug(msg="Start CPU measurement on SUT Host")
76+
return self._connection.start_process(command="esxtop -b -n 8 -d3", log_file=True)
77+
78+
def stop_cpu_measurement(self, process: RPyCProcess, vm_name: str) -> int:
79+
"""
80+
Stop CPU measurement process.
81+
82+
:param process: Process handle
83+
:param vm_name: VM name to filter CPU usage
84+
:return: Average CPU usage percentage from last 4 samples
85+
"""
86+
logger.debug(msg="Stop CPU measurement on SUT Host")
87+
if process.running:
88+
process.stop()
89+
timeout = TimeoutCounter(5)
90+
while not timeout:
91+
if not process.running:
92+
break
93+
sleep(1)
94+
else:
95+
process.kill()
96+
timeout = TimeoutCounter(5)
97+
while not timeout:
98+
if not process.running:
99+
break
100+
sleep(1)
101+
if process.running:
102+
raise RuntimeError("CPU measurement process is still running after stop and kill.")
103+
104+
return self.parse_cpu_usage(vm_name=vm_name, process=process)
105+
106+
def parse_cpu_usage(self, vm_name: str, process: RPyCProcess) -> int:
107+
"""
108+
Parse CPU usage from esxtop output.
109+
110+
:param vm_name: VM name to filter CPU usage
111+
:param process: Process handle
112+
:return: average CPU usage from last 4 samples
113+
"""
114+
parsed_file_path = "/tmp/parsed_output.txt"
115+
command = (
116+
f"cut -d, -f`awk -F, '{{for (i=1;i<=NF;i++){{if ($i ~/Group Cpu.*{vm_name}).*Used/) "
117+
f"{{print i}}}}}}' {process.log_path}` {process.log_path}>{parsed_file_path}"
118+
)
119+
self._connection.execute_command(command=command, shell=True)
120+
p = self._connection.path(process.log_path)
121+
p.unlink()
122+
try:
123+
with self._connection.modules().builtins.open(parsed_file_path, "r") as f:
124+
file_content = f.read()
125+
cpu_list = []
126+
for line in file_content.splitlines()[1:]:
127+
try:
128+
cpu_list.append(float(line.strip('"')))
129+
except ValueError:
130+
continue
131+
132+
except Exception as e:
133+
raise RuntimeError(f"Failed to read parsed CPU usage output file due to - {e}.")
134+
135+
p = self._connection.path(parsed_file_path)
136+
p.unlink()
137+
return round(sum(cpu_list[-4:]) / 4) if cpu_list else 0

tests/unit/test_mfd_host/test_feature/test_cpu/test_esxi.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from mfd_network_adapter.data_structures import State
1111
from mfd_typing import OSName
1212

13+
from mfd_host.feature.cpu import ESXiCPU
14+
1315

1416
class TestESXiCPU:
1517
cpu_attribute_output = dedent(
@@ -78,3 +80,92 @@ def test_set_numa_affinity_disabled(self, host, mocker):
7880
cmd = 'esxcli system settings advanced set -i 0 -o "/Numa/LocalityWeightActionAffinity"'
7981
assert host.cpu.set_numa_affinity(State.DISABLED) is None
8082
host.connection.execute_command.assert_called_once_with(cmd, custom_exception=CPUFeatureExecutionError)
83+
84+
def test_start_cpu_measurement_returns_process_handle(self, host, mocker):
85+
mock_connection = mocker.Mock()
86+
mock_process = mocker.Mock()
87+
mock_connection.start_process.return_value = mock_process
88+
host.cpu._connection = mock_connection
89+
90+
result = host.cpu.start_cpu_measurement()
91+
92+
assert result == mock_process
93+
mock_connection.start_process.assert_called_once_with(
94+
command="esxtop -b -n 8 -d3", log_file=True
95+
)
96+
97+
def test_logs_debug_message_when_starting_cpu_measurement(self, host, mocker):
98+
mock_logger = mocker.patch("mfd_host.feature.cpu.esxi.logger")
99+
100+
host.cpu.start_cpu_measurement()
101+
102+
mock_logger.debug.assert_called_once_with(
103+
msg="Start CPU measurement on SUT Host"
104+
)
105+
106+
def test_parses_average_cpu_usage_from_last_4_samples(self, host, mocker):
107+
mock_connection = mocker.MagicMock()
108+
mock_process = mocker.MagicMock()
109+
mock_process.log_path = "/tmp/esxtop.log"
110+
mock_connection.modules().builtins.open.return_value.read.return_value = (
111+
'Header\n"10"\n"20"\n"30"\n"40"\n"50"\n'
112+
)
113+
mock_connection.modules().builtins.open.return_value.__enter__.return_value = (
114+
mock_connection.modules().builtins.open.return_value
115+
)
116+
host.cpu._connection = mock_connection
117+
118+
result = host.cpu.parse_cpu_usage("vm1", mock_process)
119+
assert result == 35 # (20+30+40+50)//4
120+
121+
def test_returns_zero_when_no_cpu_samples_found(self, host, mocker):
122+
mock_connection = mocker.MagicMock()
123+
mock_process = mocker.MagicMock()
124+
mock_process.log_path = "/tmp/esxtop.log"
125+
mock_connection.modules().builtins.open.return_value.read.return_value = (
126+
"Header\n"
127+
)
128+
mock_connection.modules().builtins.open.return_value.__enter__.return_value = (
129+
mock_connection.modules().builtins.open.return_value
130+
)
131+
host.cpu._connection = mock_connection
132+
133+
result = host.cpu.parse_cpu_usage("vm1", mock_process)
134+
assert result == 0
135+
136+
def test_skips_lines_that_cannot_be_converted_to_float(self, host, mocker):
137+
mock_connection = mocker.MagicMock()
138+
mock_process = mocker.MagicMock()
139+
mock_process.log_path = "/tmp/esxtop.log"
140+
mock_connection.modules().builtins.open.return_value.read.return_value = (
141+
'Header\n"10"\n"invalid"\n"30"\n"40"\n"50"\n'
142+
)
143+
mock_connection.modules().builtins.open.return_value.__enter__.return_value = (
144+
mock_connection.modules().builtins.open.return_value
145+
)
146+
host.cpu._connection = mock_connection
147+
148+
result = host.cpu.parse_cpu_usage("vm1", mock_process)
149+
assert result == 32 # (10+30+40+50)//4
150+
151+
def test_raises_runtime_error_when_file_read_fails(self, host, mocker):
152+
mock_connection = mocker.MagicMock()
153+
mock_process = mocker.MagicMock()
154+
mock_process.log_path = "/tmp/esxtop.log"
155+
mock_connection.modules().builtins.open.side_effect = Exception("File error")
156+
host.cpu._connection = mock_connection
157+
158+
with pytest.raises(
159+
RuntimeError,
160+
match="Failed to read parsed CPU usage output file due to - File error.",
161+
):
162+
host.cpu.parse_cpu_usage("vm1", mock_process)
163+
164+
def test_parses_cpu_usage_when_process_not_running(self, host, mocker):
165+
mock_process = mocker.Mock()
166+
mock_process.running = False
167+
mock_parse = mocker.patch.object(ESXiCPU, "parse_cpu_usage", return_value=7)
168+
host.cpu._connection = mocker.Mock()
169+
result = host.cpu.stop_cpu_measurement(mock_process, "vm4")
170+
assert result == 7
171+
mock_parse.assert_called_once_with(vm_name="vm4", process=mock_process)

0 commit comments

Comments
 (0)