diff --git a/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/__init__.py b/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/fakes.py b/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/fakes.py new file mode 100644 index 000000000..059871134 --- /dev/null +++ b/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/fakes.py @@ -0,0 +1,22 @@ +# Copyright 2015 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. +# + +from openstackclient.tests import utils + + +class TestValidateOvercloudNetenv(utils.TestCommand): + + def setUp(self): + super(TestValidateOvercloudNetenv, self).setUp() diff --git a/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/test_overcloud_netenv_validate.py b/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/test_overcloud_netenv_validate.py new file mode 100644 index 000000000..01b765232 --- /dev/null +++ b/rdomanager_oscplugin/tests/v1/overcloud_netenv_validate/test_overcloud_netenv_validate.py @@ -0,0 +1,250 @@ +# Copyright 2015 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 +import tempfile + +import yaml + +from rdomanager_oscplugin.tests.v1.overcloud_netenv_validate import fakes +from rdomanager_oscplugin.v1 import overcloud_netenv_validate + + +class TestValidateOvercloudNetenv(fakes.TestValidateOvercloudNetenv): + + def setUp(self): + super(TestValidateOvercloudNetenv, self).setUp() + + # Get the command object to test + self.cmd = overcloud_netenv_validate.ValidateOvercloudNetenv( + self.app, None) + + def temporary_nic_config_file(self, bridges): + nic_config = { + 'resources': { + 'OsNetConfigImpl': { + 'properties': { + 'config': { + 'os_net_config': { + 'network_config': bridges, + } + } + } + } + } + } + tmp = tempfile.NamedTemporaryFile(mode='w', delete=False) + yaml.dump(nic_config, tmp) + tmp.close() + return tmp.name + + def test_cidr_no_overlapping_networks(self): + networks = [ + '172.17.0.0/24', + '172.16.0.0/24', + '172.17.1.0/24', + '172.17.2.0/24', + '10.1.2.0/24', + ] + self.cmd.error_count = 0 + self.cmd.check_cidr_overlap(networks) + self.assertEqual(0, self.cmd.error_count) + + def test_cidr_overlapping_networks(self): + networks = [ + '172.17.1.0/24', + '172.17.1.0/24', + '10.1.2.0/24', + ] + self.cmd.error_count = 0 + self.cmd.check_cidr_overlap(networks) + self.assertEqual(1, self.cmd.error_count) + + def test_cidr_nonnumerical_address(self): + networks = [ + 'nonsense', + ] + self.cmd.error_count = 0 + self.cmd.check_cidr_overlap(networks) + self.assertEqual(1, self.cmd.error_count) + + def test_cidr_address_outside_of_range(self): + networks = [ + '172.17.0.278/24', + ] + self.cmd.error_count = 0 + self.cmd.check_cidr_overlap(networks) + self.assertEqual(1, self.cmd.error_count) + + def test_vlan_ids_unique(self): + vlans = { + 'InternalApiNetworkVlanID': 201, + 'StorageNetworkVlanID': 202, + 'StorageMgmtNetworkVlanID': 203, + 'TenantNetworkVlanID': 204, + 'ExternalNetworkVlanID': 100, + } + self.cmd.error_count = 0 + self.cmd.check_vlan_ids(vlans) + self.assertEqual(0, self.cmd.error_count) + + def test_vlan_ids_duplicate(self): + vlans = { + 'InternalApiNetworkVlanID': 201, + 'StorageNetworkVlanID': 202, + 'StorageMgmtNetworkVlanID': 203, + 'TenantNetworkVlanID': 202, # conflicts with StorageNetworkVlanID + 'ExternalNetworkVlanID': 100, + } + self.cmd.error_count = 0 + self.cmd.check_vlan_ids(vlans) + self.assertEqual(1, self.cmd.error_count) + + def test_allocation_pools_pairing_no_overlap(self): + filedata = { + 'InternalApiNetCidr': '172.17.0.0/24', + 'StorageNetCidr': '172.18.0.0/24', + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.200'}], + 'StorageAllocationPools': [ + {'start': '172.18.0.10', 'end': '172.18.0.200'}], + } + pools = { + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.200'}], + 'StorageAllocationPools': [ + {'start': '172.18.0.10', 'end': '172.18.0.200'}], + } + self.cmd.error_count = 0 + self.cmd.check_allocation_pools_pairing(filedata, pools) + self.assertEqual(0, self.cmd.error_count) + + def test_allocation_pools_pairing_inverse_range(self): + filedata = { + 'InternalApiNetCidr': '172.17.0.0/24', + 'StorageNetCidr': '172.18.0.0/24', + 'InternalApiAllocationPools': [ + {'start': '172.17.0.200', 'end': '172.17.0.10'}], + 'StorageAllocationPools': [ + {'start': '172.18.0.10', 'end': '172.18.0.200'}], + } + pools = { + 'InternalApiAllocationPools': [ + {'start': '172.17.0.200', 'end': '172.17.0.10'}], + 'StorageAllocationPools': [ + {'start': '172.18.0.10', 'end': '172.18.0.200'}], + } + self.cmd.error_count = 0 + self.cmd.check_allocation_pools_pairing(filedata, pools) + self.assertEqual(1, self.cmd.error_count) + + def test_allocation_pools_pairing_pool_outside_subnet(self): + filedata = { + 'InternalApiNetCidr': '172.17.0.0/24', + 'InternalApiAllocationPools': [ + {'start': '172.16.0.10', 'end': '172.16.0.200'}], + } + pools = { + 'InternalApiAllocationPools': [ + {'start': '172.16.0.10', 'end': '172.16.0.200'}], + } + self.cmd.error_count = 0 + self.cmd.check_allocation_pools_pairing(filedata, pools) + self.assertEqual(1, self.cmd.error_count) + + def test_allocation_pools_pairing_invalid_cidr(self): + filedata = { + 'InternalApiNetCidr': '172.17.0.298/24', + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.200'}], + } + pools = { + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.200'}], + } + self.cmd.error_count = 0 + self.cmd.check_allocation_pools_pairing(filedata, pools) + self.assertEqual(1, self.cmd.error_count) + + def test_allocation_pools_pairing_invalid_range(self): + filedata = { + 'InternalApiNetCidr': '172.17.0.0/24', + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.287'}], + } + pools = { + 'InternalApiAllocationPools': [ + {'start': '172.17.0.10', 'end': '172.17.0.287'}], + } + self.cmd.error_count = 0 + self.cmd.check_allocation_pools_pairing(filedata, pools) + self.assertEqual(1, self.cmd.error_count) + + def test_nic_nonexistent_path(self): + self.cmd.error_count = 0 + self.cmd.NIC_validate('OS::TripleO::Controller::Net::SoftwareConfig', + 'this file that not exist') + self.assertEqual(1, self.cmd.error_count) + + def test_nic_valid_file(self): + bridges = [{ + 'type': 'ovs_bridge', + 'name': 'br-storage', + 'members': [ + {'type': 'interface', 'name': 'eth0'}, + {'type': 'interface', 'name': 'eth1'}, + {'type': 'ovs_bond', 'name': 'bond1'} + ], + }] + tmp = self.temporary_nic_config_file(bridges) + self.cmd.error_count = 0 + self.cmd.NIC_validate( + 'OS::TripleO::Controller::Net::SoftwareConfig', tmp) + os.unlink(tmp) + self.assertEqual(0, self.cmd.error_count) + + def test_nic_no_bond_too_many_interfaces(self): + bridges = [{ + 'type': 'ovs_bridge', + 'name': 'br-storage', + 'members': [ + {'type': 'interface', 'name': 'eth0'}, + {'type': 'interface', 'name': 'eth1'}, + ], + }] + tmp = self.temporary_nic_config_file(bridges) + self.cmd.error_count = 0 + self.cmd.NIC_validate( + 'OS::TripleO::Controller::Net::SoftwareConfig', tmp) + os.unlink(tmp) + self.assertEqual(1, self.cmd.error_count) + + def test_nic_two_bonds(self): + bridges = [{ + 'type': 'ovs_bridge', + 'name': 'br-storage', + 'members': [ + {'type': 'interface', 'name': 'eth0'}, + {'type': 'interface', 'name': 'eth1'}, + {'type': 'ovs_bond', 'name': 'bond1'}, + {'type': 'ovs_bond', 'name': 'bond2'}, + ], + }] + tmp = self.temporary_nic_config_file(bridges) + self.cmd.error_count = 0 + self.cmd.NIC_validate( + 'OS::TripleO::Controller::Net::SoftwareConfig', tmp) + os.unlink(tmp) + self.assertEqual(1, self.cmd.error_count) diff --git a/rdomanager_oscplugin/v1/overcloud_netenv_validate.py b/rdomanager_oscplugin/v1/overcloud_netenv_validate.py new file mode 100644 index 000000000..d7db11388 --- /dev/null +++ b/rdomanager_oscplugin/v1/overcloud_netenv_validate.py @@ -0,0 +1,212 @@ +# Copyright 2015 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. +# +from __future__ import print_function + +import itertools +import logging +import os + +from cliff import command +import ipaddress +import six +import yaml + + +class ValidateOvercloudNetenv(command.Command): + """Validate the network environment file.""" + + auth_required = False + log = logging.getLogger(__name__ + ".ValidateOvercloudNetworkEnvironment") + + def get_parser(self, prog_name): + parser = super(ValidateOvercloudNetenv, self).get_parser(prog_name) + parser.add_argument( + '-f', '--file', dest='netenv', + help="Path to the network environment file", + default='network-environment.yaml') + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)" % parsed_args) + + with open(parsed_args.netenv, 'r') as net_file: + network_data = yaml.load(net_file) + + cidrinfo = {} + poolsinfo = {} + vlaninfo = {} + + self.error_count = 0 + + for item in network_data['resource_registry']: + if item.endswith("Net::SoftwareConfig"): + data = network_data['resource_registry'][item] + self.log.info('Validating %s', data) + data_path = os.path.join(os.path.dirname(parsed_args.netenv), + data) + self.NIC_validate(item, data_path) + + for item in network_data['parameter_defaults']: + data = network_data['parameter_defaults'][item] + + if item.endswith('NetCidr'): + cidrinfo[item] = data + elif item.endswith('AllocationPools'): + poolsinfo[item] = data + elif item.endswith('NetworkVlanID'): + vlaninfo[item] = data + elif item == 'ExternalInterfaceDefaultRoute': + pass + elif item == 'BondInterfaceOvsOptions': + pass + + self.check_cidr_overlap(cidrinfo.values()) + self.check_allocation_pools_pairing(network_data['parameter_defaults'], + poolsinfo) + self.check_vlan_ids(vlaninfo) + + if self.error_count > 0: + print('\nFAILED Validation with %i error(s)' % self.error_count) + else: + print('SUCCESSFUL Validation with %i error(s)' % self.error_count) + + def check_cidr_overlap(self, networks): + objs = [] + for x in networks: + try: + objs += [ipaddress.ip_network(six.u(x))] + except ValueError: + self.log.error('Invalid address: %s', x) + self.error_count += 1 + + for net1, net2 in itertools.combinations(objs, 2): + if (net1.overlaps(net2)): + self.log.error( + 'Overlapping networks detected {} {}'.format(net1, net2)) + self.error_count += 1 + + def check_allocation_pools_pairing(self, filedata, pools): + for poolitem in pools: + pooldata = filedata[poolitem] + + self.log.info('Checking allocation pool {}'.format(poolitem)) + + pool_objs = [] + for pool in pooldata: + try: + ip_start = ipaddress.ip_address( + six.u(pool['start'])) + except ValueError: + self.log.error('Invalid address: %s' % ip_start) + self.error_count += 1 + ip_start = None + try: + ip_end = ipaddress.ip_address(six.u(pool['end'])) + except ValueError: + self.log.error('Invalid address: %s' % ip_start) + self.error_count += 1 + ip_end = None + if (ip_start is None) or (ip_end is None): + continue + try: + pool_objs.append(list( + ipaddress.summarize_address_range(ip_start, ip_end))) + except Exception: + self.log.error('Invalid address pool: %s, %s' % + (ip_start, ip_end)) + self.error_count += 1 + + subnet_item = poolitem.split('AllocationPools')[0] + 'NetCidr' + try: + subnet_obj = ipaddress.ip_network( + six.u(filedata[subnet_item])) + except ValueError: + self.log.error('Invalid address: %s', subnet_item) + self.error_count += 1 + continue + + for ranges in pool_objs: + for range in ranges: + if not subnet_obj.overlaps(range): + self.log.error( + 'Allocation pool {} {} outside of subnet {}: {}' + .format(poolitem, pooldata, subnet_item, + subnet_obj)) + self.error_count += 1 + break + + def check_vlan_ids(self, vlans): + invertdict = {} + for k, v in six.iteritems(vlans): + self.log.info('Checking Vlan ID {}'.format(k)) + if v not in invertdict: + invertdict[v] = k + else: + self.log.error('Vlan ID {} ({}) already exists in {}'.format( + v, k, invertdict[v])) + self.error_count += 1 + + def NIC_validate(self, resource, path): + try: + with open(path, 'r') as nic_file: + nic_data = yaml.load(nic_file) + except IOError: + self.log.error( + 'The resource "%s" reference file does not exist: "%s"', + resource, path) + self.error_count += 1 + return + + # Look though every resources bridges and make sure there is only a + # single bond per bridge and only 1 interface per bridge if there are + # no bonds. + for item in nic_data['resources']: + bridges = nic_data['resources'][item]['properties']['config'][ + 'os_net_config']['network_config'] + for bridge in bridges: + if bridge['type'] == 'ovs_bridge': + bond_count = 0 + interface_count = 0 + for bond in bridge['members']: + if bond['type'] == 'ovs_bond': + bond_count += 1 + if bond['type'] == 'interface': + interface_count += 1 + if bond_count == 0: + # Logging could be better if we knew the bridge name. + # Since it's passed as a paramter we would need to + # catch it + self.log.debug( + 'There are 0 bonds for bridge %s of ' + 'resource %s in %s', + bridge['name'], item, path) + if bond_count == 1: + self.log.debug( + 'There is 1 bond for bridge %s of ' + 'resource %s in %s', + bridge['name'], item, path) + if bond_count == 2: + self.log.error( + 'Invalid bonding: There are 2 bonds for bridge %s ' + 'of resource %s in %s', + bridge['name'], item, path) + self.error_count += 1 + if bond_count == 0 and interface_count > 1: + self.log.error( + 'Invalid interface: When not using a bond, there ' + 'can only be 1 interface for bridge %s of resource' + '%s in %s', + bridge['name'], item, path) + self.error_count += 1 diff --git a/requirements.txt b/requirements.txt index 47e5eeabb..fd2ee2736 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pbr>=0.6,!=0.7,<1.0 Babel>=1.3 cliff>=1.7.0 # Apache-2.0 +ipaddress ironic-discoverd==1.1.0 os-cloud-config python-heatclient>=0.3.0 diff --git a/setup.cfg b/setup.cfg index fa2e3039f..e25bb987e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,6 +61,7 @@ openstack.rdomanager_oscplugin.v1 = baremetal_introspection_bulk_status = rdomanager_oscplugin.v1.baremetal:StatusBaremetalIntrospectionBulk baremetal_configure_ready_state = rdomanager_oscplugin.v1.baremetal:ConfigureReadyState baremetal_configure_boot = rdomanager_oscplugin.v1.baremetal:ConfigureBaremetalBoot + overcloud_netenv_validate = rdomanager_oscplugin.v1.overcloud_netenv_validate:ValidateOvercloudNetenv overcloud_deploy = rdomanager_oscplugin.v1.overcloud_deploy:DeployOvercloud overcloud_image_build = rdomanager_oscplugin.v1.overcloud_image:BuildOvercloudImage overcloud_image_upload = rdomanager_oscplugin.v1.overcloud_image:UploadOvercloudImage