diff options
author | Zuul <zuul@review.opendev.org> | 2023-02-15 12:25:54 +0000 |
---|---|---|
committer | Gerrit Code Review <review@openstack.org> | 2023-02-15 12:25:54 +0000 |
commit | 38592c28ab7e278d20ce935eb85ab62333e56d96 (patch) | |
tree | ec4eea7355aeacc8974f61a6becb82090c22af20 | |
parent | 1330b280778fa834cffbc4ec9a8aa6b04cb2471e (diff) | |
parent | ddf96bcd31bad89ffa391251179ba13bb789991d (diff) | |
download | nova-38592c28ab7e278d20ce935eb85ab62333e56d96.tar.gz |
Merge "cpu: interfaces for managing state and governor"
-rw-r--r-- | mypy-files.txt | 4 | ||||
-rw-r--r-- | nova/conf/libvirt.py | 10 | ||||
-rw-r--r-- | nova/filesystem.py | 59 | ||||
-rw-r--r-- | nova/tests/unit/test_filesystem.py | 52 | ||||
-rw-r--r-- | nova/tests/unit/virt/libvirt/cpu/__init__.py | 0 | ||||
-rw-r--r-- | nova/tests/unit/virt/libvirt/cpu/test_api.py | 63 | ||||
-rw-r--r-- | nova/tests/unit/virt/libvirt/cpu/test_core.py | 122 | ||||
-rw-r--r-- | nova/virt/libvirt/cpu/__init__.py | 16 | ||||
-rw-r--r-- | nova/virt/libvirt/cpu/api.py | 62 | ||||
-rw-r--r-- | nova/virt/libvirt/cpu/core.py | 78 |
10 files changed, 466 insertions, 0 deletions
diff --git a/mypy-files.txt b/mypy-files.txt index 5a3b9ab339..391ed58d87 100644 --- a/mypy-files.txt +++ b/mypy-files.txt @@ -1,6 +1,7 @@ nova/compute/manager.py nova/compute/pci_placement_translator.py nova/crypto.py +nova/filesystem.py nova/limit/local.py nova/limit/placement.py nova/network/neutron.py @@ -13,6 +14,9 @@ nova/virt/driver.py nova/virt/hardware.py nova/virt/libvirt/machine_type_utils.py nova/virt/libvirt/__init__.py +nova/virt/libvirt/cpu/__init__.py +nova/virt/libvirt/cpu/api.py +nova/virt/libvirt/cpu/core.py nova/virt/libvirt/driver.py nova/virt/libvirt/event.py nova/virt/libvirt/guest.py diff --git a/nova/conf/libvirt.py b/nova/conf/libvirt.py index 16a3f63090..632bba40fb 100644 --- a/nova/conf/libvirt.py +++ b/nova/conf/libvirt.py @@ -1478,6 +1478,15 @@ Related options: """), ] +libvirt_cpu_mgmt_opts = [ + cfg.StrOpt('cpu_power_governor_low', + default='powersave', + help='Governor to use in order ' + 'to reduce CPU power consumption'), + cfg.StrOpt('cpu_power_governor_high', + default='performance', + help='Governor to use in order to have best CPU performance'), +] ALL_OPTS = list(itertools.chain( libvirt_general_opts, @@ -1499,6 +1508,7 @@ ALL_OPTS = list(itertools.chain( libvirt_volume_nvmeof_opts, libvirt_pmem_opts, libvirt_vtpm_opts, + libvirt_cpu_mgmt_opts, )) diff --git a/nova/filesystem.py b/nova/filesystem.py new file mode 100644 index 0000000000..5394d2d835 --- /dev/null +++ b/nova/filesystem.py @@ -0,0 +1,59 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Functions to address filesystem calls, particularly sysfs.""" + +import os + +from oslo_log import log as logging + +from nova import exception + +LOG = logging.getLogger(__name__) + + +SYS = '/sys' + + +# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint +def read_sys(path: str) -> str: + """Reads the content of a file in the sys filesystem. + + :param path: relative or absolute. If relative, will be prefixed by /sys. + :returns: contents of that file. + :raises: nova.exception.FileNotFound if we can't read that file. + """ + try: + # The path can be absolute with a /sys prefix but that's fine. + with open(os.path.join(SYS, path), mode='r') as data: + return data.read() + except (OSError, ValueError) as exc: + raise exception.FileNotFound(file_path=path) from exc + + +# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint +# In order to correctly use it, you need to decorate the caller with a specific +# privsep entrypoint. +def write_sys(path: str, data: str) -> None: + """Writes the content of a file in the sys filesystem with data. + + :param path: relative or absolute. If relative, will be prefixed by /sys. + :param data: the data to write. + :returns: contents of that file. + :raises: nova.exception.FileNotFound if we can't write that file. + """ + try: + # The path can be absolute with a /sys prefix but that's fine. + with open(os.path.join(SYS, path), mode='w') as fd: + fd.write(data) + except (OSError, ValueError) as exc: + raise exception.FileNotFound(file_path=path) from exc diff --git a/nova/tests/unit/test_filesystem.py b/nova/tests/unit/test_filesystem.py new file mode 100644 index 0000000000..85f16157ee --- /dev/null +++ b/nova/tests/unit/test_filesystem.py @@ -0,0 +1,52 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +from unittest import mock + +from nova import exception +from nova import filesystem +from nova import test + + +class TestFSCommon(test.NoDBTestCase): + + def test_read_sys(self): + open_mock = mock.mock_open(read_data='bar') + with mock.patch('builtins.open', open_mock) as m_open: + self.assertEqual('bar', filesystem.read_sys('foo')) + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='r') + + def test_read_sys_error(self): + with mock.patch('builtins.open', + side_effect=OSError('error')) as m_open: + self.assertRaises(exception.FileNotFound, + filesystem.read_sys, 'foo') + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='r') + + def test_write_sys(self): + open_mock = mock.mock_open() + with mock.patch('builtins.open', open_mock) as m_open: + self.assertIsNone(filesystem.write_sys('foo', 'bar')) + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='w') + open_mock().write.assert_called_once_with('bar') + + def test_write_sys_error(self): + with mock.patch('builtins.open', + side_effect=OSError('fake_error')) as m_open: + self.assertRaises(exception.FileNotFound, + filesystem.write_sys, 'foo', 'bar') + expected_path = os.path.join(filesystem.SYS, 'foo') + m_open.assert_called_once_with(expected_path, mode='w') diff --git a/nova/tests/unit/virt/libvirt/cpu/__init__.py b/nova/tests/unit/virt/libvirt/cpu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/nova/tests/unit/virt/libvirt/cpu/__init__.py diff --git a/nova/tests/unit/virt/libvirt/cpu/test_api.py b/nova/tests/unit/virt/libvirt/cpu/test_api.py new file mode 100644 index 0000000000..d47b3690a3 --- /dev/null +++ b/nova/tests/unit/virt/libvirt/cpu/test_api.py @@ -0,0 +1,63 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from nova import test +from nova.virt.libvirt.cpu import api +from nova.virt.libvirt.cpu import core + + +class TestAPI(test.NoDBTestCase): + + def setUp(self): + super(TestAPI, self).setUp() + self.core_1 = api.Core(1) + + @mock.patch.object(core, 'get_online') + def test_online(self, mock_get_online): + mock_get_online.return_value = True + self.assertTrue(self.core_1.online) + mock_get_online.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_online') + def test_set_online(self, mock_set_online): + self.core_1.online = True + mock_set_online.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_offline') + def test_set_offline(self, mock_set_offline): + self.core_1.online = False + mock_set_offline.assert_called_once_with(self.core_1.ident) + + def test_hash(self): + self.assertEqual(hash(self.core_1.ident), hash(self.core_1)) + + @mock.patch.object(core, 'get_governor') + def test_governor(self, mock_get_governor): + mock_get_governor.return_value = 'fake_governor' + self.assertEqual('fake_governor', self.core_1.governor) + mock_get_governor.assert_called_once_with(self.core_1.ident) + + @mock.patch.object(core, 'set_governor') + def test_set_governor_low(self, mock_set_governor): + self.flags(cpu_power_governor_low='fake_low_gov', group='libvirt') + self.core_1.set_low_governor() + mock_set_governor.assert_called_once_with(self.core_1.ident, + 'fake_low_gov') + + @mock.patch.object(core, 'set_governor') + def test_set_governor_high(self, mock_set_governor): + self.flags(cpu_power_governor_high='fake_high_gov', group='libvirt') + self.core_1.set_high_governor() + mock_set_governor.assert_called_once_with(self.core_1.ident, + 'fake_high_gov') diff --git a/nova/tests/unit/virt/libvirt/cpu/test_core.py b/nova/tests/unit/virt/libvirt/cpu/test_core.py new file mode 100644 index 0000000000..a3cba00d3b --- /dev/null +++ b/nova/tests/unit/virt/libvirt/cpu/test_core.py @@ -0,0 +1,122 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from nova import exception +from nova import test +from nova.tests import fixtures +from nova.virt.libvirt.cpu import core + + +class TestCore(test.NoDBTestCase): + + @mock.patch.object(core.filesystem, 'read_sys') + @mock.patch.object(core.hardware, 'parse_cpu_spec') + def test_get_available_cores(self, mock_parse_cpu_spec, mock_read_sys): + mock_read_sys.return_value = '1-2' + mock_parse_cpu_spec.return_value = set([1, 2]) + self.assertEqual(set([1, 2]), core.get_available_cores()) + mock_read_sys.assert_called_once_with(core.AVAILABLE_PATH) + mock_parse_cpu_spec.assert_called_once_with('1-2') + + @mock.patch.object(core.filesystem, 'read_sys') + @mock.patch.object(core.hardware, 'parse_cpu_spec') + def test_get_available_cores_none( + self, mock_parse_cpu_spec, mock_read_sys): + mock_read_sys.return_value = '' + self.assertEqual(set(), core.get_available_cores()) + mock_parse_cpu_spec.assert_not_called() + + @mock.patch.object(core, 'get_available_cores') + def test_exists(self, mock_get_available_cores): + mock_get_available_cores.return_value = set([1]) + self.assertTrue(core.exists(1)) + mock_get_available_cores.assert_called_once_with() + self.assertFalse(core.exists(2)) + + @mock.patch.object( + core, 'CPU_PATH_TEMPLATE', + new_callable=mock.PropertyMock(return_value='/sys/blah%(core)s')) + @mock.patch.object(core, 'exists') + def test_gen_cpu_path(self, mock_exists, mock_cpu_path): + mock_exists.return_value = True + self.assertEqual('/sys/blah1', core.gen_cpu_path(1)) + mock_exists.assert_called_once_with(1) + + @mock.patch.object(core, 'exists') + def test_gen_cpu_path_raises(self, mock_exists): + mock_exists.return_value = False + self.assertRaises(ValueError, core.gen_cpu_path, 1) + self.assertIn('Unable to access CPU: 1', self.stdlog.logger.output) + + +class TestCoreHelpers(test.NoDBTestCase): + + def setUp(self): + super(TestCoreHelpers, self).setUp() + self.useFixture(fixtures.PrivsepFixture()) + _p1 = mock.patch.object(core, 'exists', return_value=True) + self.mock_exists = _p1.start() + self.addCleanup(_p1.stop) + + _p2 = mock.patch.object(core, 'gen_cpu_path', + side_effect=lambda x: '/fakesys/blah%s' % x) + self.mock_gen_cpu_path = _p2.start() + self.addCleanup(_p2.stop) + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_online(self, mock_read_sys): + mock_read_sys.return_value = '1' + self.assertTrue(core.get_online(1)) + mock_read_sys.assert_called_once_with('/fakesys/blah1/online') + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_online_not_exists(self, mock_read_sys): + mock_read_sys.side_effect = exception.FileNotFound(file_path='foo') + self.assertTrue(core.get_online(1)) + mock_read_sys.assert_called_once_with('/fakesys/blah1/online') + + @mock.patch.object(core.filesystem, 'write_sys') + @mock.patch.object(core, 'get_online') + def test_set_online(self, mock_get_online, mock_write_sys): + mock_get_online.return_value = True + self.assertTrue(core.set_online(1)) + mock_write_sys.assert_called_once_with('/fakesys/blah1/online', + data='1') + mock_get_online.assert_called_once_with(1) + + @mock.patch.object(core.filesystem, 'write_sys') + @mock.patch.object(core, 'get_online') + def test_set_offline(self, mock_get_online, mock_write_sys): + mock_get_online.return_value = False + self.assertTrue(core.set_offline(1)) + mock_write_sys.assert_called_once_with('/fakesys/blah1/online', + data='0') + mock_get_online.assert_called_once_with(1) + + @mock.patch.object(core.filesystem, 'read_sys') + def test_get_governor(self, mock_read_sys): + mock_read_sys.return_value = 'fake_gov' + self.assertEqual('fake_gov', core.get_governor(1)) + mock_read_sys.assert_called_once_with( + '/fakesys/blah1/cpufreq/scaling_governor') + + @mock.patch.object(core, 'get_governor') + @mock.patch.object(core.filesystem, 'write_sys') + def test_set_governor(self, mock_write_sys, mock_get_governor): + mock_get_governor.return_value = 'fake_gov' + self.assertEqual('fake_gov', + core.set_governor(1, 'fake_gov')) + mock_write_sys.assert_called_once_with( + '/fakesys/blah1/cpufreq/scaling_governor', data='fake_gov') + mock_get_governor.assert_called_once_with(1) diff --git a/nova/virt/libvirt/cpu/__init__.py b/nova/virt/libvirt/cpu/__init__.py new file mode 100644 index 0000000000..962c9469a0 --- /dev/null +++ b/nova/virt/libvirt/cpu/__init__.py @@ -0,0 +1,16 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.virt.libvirt.cpu import api + + +Core = api.Core diff --git a/nova/virt/libvirt/cpu/api.py b/nova/virt/libvirt/cpu/api.py new file mode 100644 index 0000000000..e0b0a277d1 --- /dev/null +++ b/nova/virt/libvirt/cpu/api.py @@ -0,0 +1,62 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass + +from oslo_log import log as logging + +import nova.conf +from nova.virt.libvirt.cpu import core + +LOG = logging.getLogger(__name__) + +CONF = nova.conf.CONF + + +@dataclass +class Core: + """Class to model a CPU core as reported by sysfs. + + It may be a physical CPU core or a hardware thread on a shared CPU core + depending on if the system supports SMT. + """ + + # NOTE(sbauza): ident is a mandatory field. + # The CPU core id/number + ident: int + + @property + def online(self) -> bool: + return core.get_online(self.ident) + + @online.setter + def online(self, state: bool) -> None: + if state: + core.set_online(self.ident) + else: + core.set_offline(self.ident) + + def __hash__(self): + return hash(self.ident) + + def __eq__(self, other): + return self.ident == other.ident + + @property + def governor(self) -> str: + return core.get_governor(self.ident) + + def set_high_governor(self) -> None: + core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_high) + + def set_low_governor(self) -> None: + core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_low) diff --git a/nova/virt/libvirt/cpu/core.py b/nova/virt/libvirt/cpu/core.py new file mode 100644 index 0000000000..782f028fee --- /dev/null +++ b/nova/virt/libvirt/cpu/core.py @@ -0,0 +1,78 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import typing as ty + +from oslo_log import log as logging + +from nova import exception +from nova import filesystem +import nova.privsep +from nova.virt import hardware + +LOG = logging.getLogger(__name__) + +AVAILABLE_PATH = '/sys/devices/system/cpu/present' + +CPU_PATH_TEMPLATE = '/sys/devices/system/cpu/cpu%(core)s' + + +def get_available_cores() -> ty.Set[int]: + cores = filesystem.read_sys(AVAILABLE_PATH) + return hardware.parse_cpu_spec(cores) if cores else set() + + +def exists(core: int) -> bool: + return core in get_available_cores() + + +def gen_cpu_path(core: int) -> str: + if not exists(core): + LOG.warning('Unable to access CPU: %s', core) + raise ValueError('CPU: %(core)s does not exist', core) + return CPU_PATH_TEMPLATE % {'core': core} + + +def get_online(core: int) -> bool: + try: + online = filesystem.read_sys( + os.path.join(gen_cpu_path(core), 'online')).strip() + except exception.FileNotFound: + # The online file may not exist if we haven't written it yet. + # By default, this means that the CPU is online. + online = '1' + return online == '1' + + +@nova.privsep.sys_admin_pctxt.entrypoint +def set_online(core: int) -> bool: + filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='1') + return get_online(core) + + +def set_offline(core: int) -> bool: + filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='0') + return not get_online(core) + + +def get_governor(core: int) -> str: + return filesystem.read_sys( + os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor')).strip() + + +@nova.privsep.sys_admin_pctxt.entrypoint +def set_governor(core: int, governor: str) -> str: + filesystem.write_sys( + os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor'), + data=governor) + return get_governor(core) |