# 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 copy import ddt import fixtures import microversion_parse import os from unittest import mock from oslo_utils.fixture import uuidsentinel from oslotest import base from nova.compute import provider_config from nova import exception as nova_exc class SchemaValidationMixin(base.BaseTestCase): """This class provides the basic methods for running schema validation test cases. It can be used along with ddt.file_data to test a specific schema version using tests defined in yaml files. See SchemaValidationTestCasesV1 for an example of how this was done for schema version 1. Because decorators can only access class properties of the class they are defined in (even when overriding values in the subclass), the decorators need to be placed in the subclass. This is why there are test_ functions in the subclass that call the run_test_ methods in this class. This should keep things simple as more schema versions are added. """ def setUp(self): super(SchemaValidationMixin, self).setUp() self.mock_load_yaml = self.useFixture( fixtures.MockPatchObject( provider_config, '_load_yaml_file')).mock self.mock_LOG = self.useFixture( fixtures.MockPatchObject( provider_config, 'LOG')).mock def set_config(self, config=None): data = config or {} self.mock_load_yaml.return_value = data return data def run_test_validation_errors(self, config, expected_messages): self.set_config(config=config) actual_msg = self.assertRaises( nova_exc.ProviderConfigException, provider_config._parse_provider_yaml, 'test_path').message for msg in expected_messages: self.assertIn(msg, actual_msg) def run_test_validation_success(self, config): reference = self.set_config(config=config) actual = provider_config._parse_provider_yaml('test_path') self.assertEqual(reference, actual) def run_schema_version_matching( self, min_schema_version, max_schema_version): # note _load_yaml_file is mocked so the value is not important # however it may appear in logs messages so changing it could # result in tests failing unless the expected_messages field # is updated in the test data. path = 'test_path' # test exactly min and max versions are supported self.set_config(config={ 'meta': {'schema_version': str(min_schema_version)}}) provider_config._parse_provider_yaml(path) self.set_config(config={ 'meta': {'schema_version': str(max_schema_version)}}) provider_config._parse_provider_yaml(path) self.mock_LOG.warning.assert_not_called() # test max major+1 raises higher_major = microversion_parse.Version( major=max_schema_version.major + 1, minor=max_schema_version.minor) self.set_config(config={'meta': {'schema_version': str(higher_major)}}) self.assertRaises(nova_exc.ProviderConfigException, provider_config._parse_provider_yaml, path) # test max major with max minor+1 is logged higher_minor = microversion_parse.Version( major=max_schema_version.major, minor=max_schema_version.minor + 1) expected_log_call = ( "Provider config file [%(path)s] is at schema version " "%(schema_version)s. Nova supports the major version, but " "not the minor. Some fields may be ignored." % { "path": path, "schema_version": higher_minor}) self.set_config(config={'meta': {'schema_version': str(higher_minor)}}) provider_config._parse_provider_yaml(path) self.mock_LOG.warning.assert_called_once_with(expected_log_call) @ddt.ddt class SchemaValidationTestCasesV1(SchemaValidationMixin): MIN_SCHEMA_VERSION = microversion_parse.Version(1, 0) MAX_SCHEMA_VERSION = microversion_parse.Version(1, 0) @ddt.unpack @ddt.file_data('provider_config_data/v1/validation_error_test_data.yaml') def test_validation_errors(self, config, expected_messages): self.run_test_validation_errors(config, expected_messages) @ddt.unpack @ddt.file_data('provider_config_data/v1/validation_success_test_data.yaml') def test_validation_success(self, config): self.run_test_validation_success(config) def test_schema_version_matching(self): self.run_schema_version_matching(self.MIN_SCHEMA_VERSION, self.MAX_SCHEMA_VERSION) @ddt.ddt class ValidateProviderConfigTestCases(base.BaseTestCase): @ddt.unpack @ddt.file_data('provider_config_data/validate_provider_good_config.yaml') def test__validate_provider_good_config(self, sample): provider_config._validate_provider_config(sample, "fake_path") @ddt.unpack @ddt.file_data('provider_config_data/validate_provider_bad_config.yaml') def test__validate_provider_bad_config(self, sample, expected_messages): actual_msg = self.assertRaises( nova_exc.ProviderConfigException, provider_config._validate_provider_config, sample, 'fake_path').message self.assertIn(actual_msg, expected_messages) @mock.patch.object(provider_config, 'LOG') def test__validate_provider_config_one_noop_provider(self, mock_log): expected = { "providers": [ { "identification": {"name": "NAME1"}, "inventories": { "additional": [ {"CUSTOM_RESOURCE_CLASS": {}} ] } }, { "identification": {"name": "NAME_453764"}, "inventories": { "additional": [] }, "traits": { "additional": [] } } ] } data = copy.deepcopy(expected) valid = provider_config._validate_provider_config(data, "fake_path") mock_log.warning.assert_called_once_with( "Provider NAME_453764 defined in " "fake_path has no additional " "inventories or traits and will be ignored." ) # assert that _validate_provider_config does not mutate inputs self.assertEqual(expected, data) # assert that the first entry in the returned tuple is the full set # of providers not a copy and is equal to the expected providers. self.assertIs(data['providers'][0], valid[0]) self.assertEqual(expected['providers'][0], valid[0]) class GetProviderConfigsTestCases(base.BaseTestCase): @mock.patch.object(provider_config, 'glob') def test_get_provider_configs_one_file(self, mock_glob): expected = { "$COMPUTE_NODE": { "__source_file": "example_provider.yaml", "identification": { "name": "$COMPUTE_NODE" }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1.0 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT_ONE", "CUSTOM_TRAIT2" ] } } } example_file = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'provider_config_data/v1/example_provider.yaml') mock_glob.glob.return_value = [example_file] actual = provider_config.get_provider_configs('path') self.assertEqual(expected, actual) mock_glob.glob.assert_called_with('path/*.yaml') @mock.patch.object(provider_config, 'glob') @mock.patch.object(provider_config, '_parse_provider_yaml') def test_get_provider_configs_one_file_uuid_conflict( self, mock_parser, mock_glob): # one config file with conflicting identification providers = [ {"__source_file": "file1.yaml", "identification": { "uuid": uuidsentinel.uuid1 }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS1": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT1" ] } }, {"__source_file": "file1.yaml", "identification": { "uuid": uuidsentinel.uuid1 }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS2": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT2" ] } } ] mock_parser.side_effect = [{"providers": providers}] mock_glob.glob.return_value = ['file1.yaml'] # test that correct error is raised and message matches error = self.assertRaises(nova_exc.ProviderConfigException, provider_config.get_provider_configs, 'dummy_path').kwargs['error'] self.assertEqual("Provider %s has multiple definitions in source " "file(s): ['file1.yaml']." % uuidsentinel.uuid1, error) @mock.patch.object(provider_config, 'glob') @mock.patch.object(provider_config, '_parse_provider_yaml') def test_get_provider_configs_two_files(self, mock_parser, mock_glob): expected = { "EXAMPLE_RESOURCE_PROVIDER1": { "__source_file": "file1.yaml", "identification": { "name": "EXAMPLE_RESOURCE_PROVIDER1" }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS1": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT1" ] } }, "EXAMPLE_RESOURCE_PROVIDER2": { "__source_file": "file2.yaml", "identification": { "name": "EXAMPLE_RESOURCE_PROVIDER2" }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS2": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT2" ] } } } mock_parser.side_effect = [ {"providers": [provider]} for provider in expected.values()] mock_glob_return = ['file1.yaml', 'file2.yaml'] mock_glob.glob.return_value = mock_glob_return dummy_path = 'dummy_path' actual = provider_config.get_provider_configs(dummy_path) mock_glob.glob.assert_called_once_with(os.path.join(dummy_path, '*.yaml')) mock_parser.assert_has_calls([mock.call(param) for param in mock_glob_return]) self.assertEqual(expected, actual) @mock.patch.object(provider_config, 'glob') @mock.patch.object(provider_config, '_parse_provider_yaml') def test_get_provider_configs_two_files_name_conflict(self, mock_parser, mock_glob): # two config files with conflicting identification configs = { "EXAMPLE_RESOURCE_PROVIDER1": { "__source_file": "file1.yaml", "identification": { "name": "EXAMPLE_RESOURCE_PROVIDER1" }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS1": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT1" ] } }, "EXAMPLE_RESOURCE_PROVIDER2": { "__source_file": "file2.yaml", "identification": { "name": "EXAMPLE_RESOURCE_PROVIDER1" }, "inventories": { "additional": [ { "CUSTOM_EXAMPLE_RESOURCE_CLASS1": { "total": 100, "reserved": 0, "min_unit": 1, "max_unit": 10, "step_size": 1, "allocation_ratio": 1 } } ] }, "traits": { "additional": [ "CUSTOM_TRAIT1" ] } } } mock_parser.side_effect = [{"providers": [configs[provider]]} for provider in configs] mock_glob.glob.return_value = ['file1.yaml', 'file2.yaml'] # test that correct error is raised and message matches error = self.assertRaises(nova_exc.ProviderConfigException, provider_config.get_provider_configs, 'dummy_path').kwargs['error'] self.assertEqual("Provider EXAMPLE_RESOURCE_PROVIDER1 has multiple " "definitions in source file(s): " "['file1.yaml', 'file2.yaml'].", error) @mock.patch.object(provider_config, 'LOG') def test_get_provider_configs_no_configs(self, mock_log): path = "invalid_path!@#" actual = provider_config.get_provider_configs(path) self.assertEqual({}, actual) mock_log.info.assert_called_once_with( "No provider configs found in %s. If files are present, " "ensure the Nova process has access.", path)