diff options
author | Dan Smith <dansmith@redhat.com> | 2022-11-02 09:19:35 -0700 |
---|---|---|
committer | Dan Smith <dansmith@redhat.com> | 2023-01-20 07:22:02 -0800 |
commit | 3b33b0938ea4f5b00537298bc7fcb531fb9ec811 (patch) | |
tree | 5104a20d09600ccd0045f6c3deee16f05be478c6 | |
parent | 6abbcc5033c86c1503d2e8018687476c16ebf6f2 (diff) | |
download | nova-3b33b0938ea4f5b00537298bc7fcb531fb9ec811.tar.gz |
Add virt/node module for stable uuids
Related to blueprint stable-compute-uuid
Change-Id: Ie8897a843fadf325c696b411923f075e237a7342
-rw-r--r-- | nova/exception.py | 4 | ||||
-rw-r--r-- | nova/test.py | 6 | ||||
-rw-r--r-- | nova/tests/fixtures/nova.py | 12 | ||||
-rw-r--r-- | nova/tests/functional/integrated_helpers.py | 4 | ||||
-rw-r--r-- | nova/tests/unit/virt/test_node.py | 141 | ||||
-rw-r--r-- | nova/virt/node.py | 107 |
6 files changed, 274 insertions, 0 deletions
diff --git a/nova/exception.py b/nova/exception.py index cebaea4bee..f89dc107fa 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -2496,3 +2496,7 @@ class PlacementPciMixedTraitsException(PlacementPciException): class ReimageException(NovaException): msg_fmt = _("Reimaging volume failed.") + + +class InvalidNodeConfiguration(NovaException): + msg_fmt = _('Invalid node identity configuration: %(reason)s') diff --git a/nova/test.py b/nova/test.py index 65527ea093..f050dde3f9 100644 --- a/nova/test.py +++ b/nova/test.py @@ -66,6 +66,7 @@ from nova.tests import fixtures as nova_fixtures from nova.tests.unit import matchers from nova import utils from nova.virt import images +from nova.virt import node CONF = cfg.CONF @@ -299,6 +300,11 @@ class TestCase(base.BaseTestCase): # Reset the placement client singleton report.PLACEMENTCLIENT = None + # Reset our local node uuid cache (and avoid writing to the + # local filesystem when we generate a new one). + node.LOCAL_NODE_UUID = None + self.useFixture(nova_fixtures.ComputeNodeIdFixture()) + def _setup_cells(self): """Setup a normal cellsv2 environment. diff --git a/nova/tests/fixtures/nova.py b/nova/tests/fixtures/nova.py index 0c455e54e9..54ef4d2b8d 100644 --- a/nova/tests/fixtures/nova.py +++ b/nova/tests/fixtures/nova.py @@ -1849,3 +1849,15 @@ class ImportModulePoisonFixture(fixtures.Fixture): # will not work to cause a failure in the test. if self.fail_message: raise ImportError(self.fail_message) + + +class ComputeNodeIdFixture(fixtures.Fixture): + def setUp(self): + super().setUp() + + self.useFixture(fixtures.MockPatch( + 'nova.virt.node.read_local_node_uuid', + lambda: None)) + self.useFixture(fixtures.MockPatch( + 'nova.virt.node.write_local_node_uuid', + lambda uuid: None)) diff --git a/nova/tests/functional/integrated_helpers.py b/nova/tests/functional/integrated_helpers.py index ca6fd48e8c..2dd840259f 100644 --- a/nova/tests/functional/integrated_helpers.py +++ b/nova/tests/functional/integrated_helpers.py @@ -1230,6 +1230,8 @@ class _IntegratedTestBase(test.TestCase, PlacementInstanceHelperMixin): self.glance = self.useFixture(nova_fixtures.GlanceFixture(self)) self.policy = self.useFixture(nova_fixtures.RealPolicyFixture()) + self.useFixture(nova_fixtures.ComputeNodeIdFixture()) + self.notifier = self.useFixture( nova_fixtures.NotificationFixture(self)) @@ -1301,6 +1303,8 @@ class ProviderUsageBaseTestCase(test.TestCase, PlacementInstanceHelperMixin): self.placement = self.useFixture(func_fixtures.PlacementFixture()).api self.useFixture(nova_fixtures.AllServicesCurrent()) + self.useFixture(nova_fixtures.ComputeNodeIdFixture()) + self.notifier = self.useFixture( nova_fixtures.NotificationFixture(self)) diff --git a/nova/tests/unit/virt/test_node.py b/nova/tests/unit/virt/test_node.py new file mode 100644 index 0000000000..cc99268eb4 --- /dev/null +++ b/nova/tests/unit/virt/test_node.py @@ -0,0 +1,141 @@ +# Copyright 2022 Red Hat, inc. +# +# 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 +import uuid + +import fixtures +from oslo_config import cfg +from oslo_utils.fixture import uuidsentinel as uuids +import testtools + +from nova import exception +from nova import test +from nova.tests import fixtures as nova_fixtures +from nova.virt import node + +CONF = cfg.CONF + + +# NOTE(danms): We do not inherit from test.TestCase because we need +# our node methods not stubbed out in order to exercise them. +class TestNodeIdentity(testtools.TestCase): + def flags(self, **kw): + """Override flag variables for a test.""" + group = kw.pop('group', None) + for k, v in kw.items(): + CONF.set_override(k, v, group) + + def setUp(self): + super().setUp() + self.useFixture(nova_fixtures.ConfFixture(CONF)) + self.tempdir = self.useFixture(fixtures.TempDir()).path + self.identity_file = os.path.join(self.tempdir, node.COMPUTE_ID_FILE) + self.fake_config_files = ['%s/etc/nova.conf' % self.tempdir, + '%s/etc/nova/nova.conf' % self.tempdir, + '%s/opt/etc/nova/nova.conf' % self.tempdir] + for fn in self.fake_config_files: + os.makedirs(os.path.dirname(fn)) + self.flags(state_path=self.tempdir, + config_file=self.fake_config_files) + node.LOCAL_NODE_UUID = None + + def test_generate_local_node_uuid(self): + node_uuid = uuids.node + node.write_local_node_uuid(node_uuid) + + e = self.assertRaises(exception.InvalidNodeConfiguration, + node.write_local_node_uuid, 'anything') + self.assertIn( + 'Identity file %s appeared unexpectedly' % self.identity_file, + str(e)) + + def test_generate_local_node_uuid_unexpected_open_fail(self): + with mock.patch('builtins.open') as mock_open: + mock_open.side_effect = IndexError() + e = self.assertRaises(exception.InvalidNodeConfiguration, + node.write_local_node_uuid, 'foo') + self.assertIn('Unable to write uuid to %s' % ( + self.identity_file), str(e)) + + def test_generate_local_node_uuid_unexpected_write_fail(self): + with mock.patch('builtins.open') as mock_open: + mock_open.return_value.write.side_effect = IndexError() + e = self.assertRaises(exception.InvalidNodeConfiguration, + node.write_local_node_uuid, 'foo') + self.assertIn('Unable to write uuid to %s' % ( + self.identity_file), str(e)) + + def test_get_local_node_uuid_simple_exists(self): + node_uuid = uuids.node + with test.patch_open('%s/etc/nova/compute_id' % self.tempdir, + node_uuid): + self.assertEqual(node_uuid, node.get_local_node_uuid()) + + def test_get_local_node_uuid_simple_exists_whitespace(self): + node_uuid = uuids.node + # Make sure we strip whitespace from the file contents + with test.patch_open('%s/etc/nova/compute_id' % self.tempdir, + ' %s \n' % node_uuid): + self.assertEqual(node_uuid, node.get_local_node_uuid()) + + def test_get_local_node_uuid_simple_generate(self): + self.assertIsNone(node.LOCAL_NODE_UUID) + node_uuid1 = node.get_local_node_uuid() + self.assertEqual(node_uuid1, node.LOCAL_NODE_UUID) + node_uuid2 = node.get_local_node_uuid() + self.assertEqual(node_uuid2, node.LOCAL_NODE_UUID) + + # Make sure we got the same thing each time, and that it's a + # valid uuid. Since we provided no uuid, it must have been + # generated the first time and read/returned the second. + self.assertEqual(node_uuid1, node_uuid2) + uuid.UUID(node_uuid1) + + # Try to read it directly to make sure the file was really + # created and with the right value. + self.assertEqual(node_uuid1, node.read_local_node_uuid()) + + def test_get_local_node_uuid_two(self): + node_uuid = uuids.node + + # Write the uuid to two of our locations + for cf in (self.fake_config_files[0], self.fake_config_files[1]): + open(os.path.join(os.path.dirname(cf), + node.COMPUTE_ID_FILE), 'w').write(node_uuid) + + # Make sure we got the expected uuid and that no exceptions + # were raised about the files disagreeing + self.assertEqual(node_uuid, node.get_local_node_uuid()) + + def test_get_local_node_uuid_two_mismatch(self): + node_uuids = [uuids.node1, uuids.node2] + + # Write a different uuid to each file + for id, fn in zip(node_uuids, self.fake_config_files): + open(os.path.join( + os.path.dirname(fn), + node.COMPUTE_ID_FILE), 'w').write(id) + + # Make sure we get an error that identifies the mismatching + # file with its uuid, as well as what we expected to find + e = self.assertRaises(exception.InvalidNodeConfiguration, + node.get_local_node_uuid) + expected = ('UUID %s in %s does not match %s' % ( + node_uuids[1], + os.path.join(os.path.dirname(self.fake_config_files[1]), + 'compute_id'), + node_uuids[0])) + self.assertIn(expected, str(e)) diff --git a/nova/virt/node.py b/nova/virt/node.py new file mode 100644 index 0000000000..e567ad28e2 --- /dev/null +++ b/nova/virt/node.py @@ -0,0 +1,107 @@ +# Copyright 2022 Red Hat, inc. +# +# 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 logging +import os +import uuid + +from oslo_utils import uuidutils + +import nova.conf +from nova import exception + +CONF = nova.conf.CONF +LOG = logging.getLogger(__name__) +COMPUTE_ID_FILE = 'compute_id' +LOCAL_NODE_UUID = None + + +def write_local_node_uuid(node_uuid): + # We only ever write an identity file in the CONF.state_path + # location + fn = os.path.join(CONF.state_path, COMPUTE_ID_FILE) + + # Try to create the identity file and write our uuid into it. Fail + # if the file exists (since it shouldn't if we made it here). + try: + open(fn, 'x').write(node_uuid) + except FileExistsError: + # If the file exists, we must either fail or re-survey all the + # potential files. If we just read and return it, it could be + # inconsistent with files in the other locations. + raise exception.InvalidNodeConfiguration( + reason='Identity file %s appeared unexpectedly' % fn) + except Exception as e: + raise exception.InvalidNodeConfiguration( + reason='Unable to write uuid to %s: %s' % (fn, e)) + + LOG.info('Wrote node identity %s to %s', node_uuid, fn) + + +def read_local_node_uuid(): + locations = ([os.path.dirname(f) for f in CONF.config_file] + + [CONF.state_path]) + + uuids = [] + found = [] + for location in locations: + fn = os.path.join(location, COMPUTE_ID_FILE) + try: + # UUIDs should be 36 characters in canonical format. Read + # a little more to be graceful about whitespace in/around + # the actual value we want to read. However, it must parse + # to a legit UUID once we strip the whitespace. + with open(fn) as f: + content = f.read(40) + node_uuid = str(uuid.UUID(content.strip())) + except FileNotFoundError: + continue + except ValueError: + raise exception.InvalidNodeConfiguration( + reason='Unable to parse UUID from %s' % fn) + uuids.append(node_uuid) + found.append(fn) + + if uuids: + # Any identities we found must be consistent, or we fail + first = uuids[0] + for i, (node_uuid, fn) in enumerate(zip(uuids, found)): + if node_uuid != first: + raise exception.InvalidNodeConfiguration( + reason='UUID %s in %s does not match %s' % ( + node_uuid, fn, uuids[i - 1])) + LOG.info('Determined node identity %s from %s', first, found[0]) + return first + else: + return None + + +def get_local_node_uuid(): + """Read or create local node uuid file. + + :returns: UUID string read from file, or generated + """ + global LOCAL_NODE_UUID + + if LOCAL_NODE_UUID is not None: + return LOCAL_NODE_UUID + + node_uuid = read_local_node_uuid() + if not node_uuid: + node_uuid = uuidutils.generate_uuid() + LOG.info('Generated node identity %s', node_uuid) + write_local_node_uuid(node_uuid) + + LOCAL_NODE_UUID = node_uuid + return node_uuid |