Write network_data.json metadata to config-dirve

cloud-init will use fall-back config when network metadata
is *not* present. This works fine if the first NIC on the node is
connected to a network with DHCP. But, when the first NIC is not
used for provisioning, cloud-init will only write a fallback config
for the first NIC. This causes the provisioned node to be unavailable.

Extend instance configuration to include network metadata so that
cloud-init can configure node networking.

Story: 2009238
Task: 43378
Change-Id: I70f1a972a6d5a0398cd348f00308957386d66067
This commit is contained in:
Harald Jensås 2021-09-21 14:17:07 +02:00
parent 56cf1fe6f3
commit f98dfa61c1
8 changed files with 285 additions and 14 deletions

View File

@ -0,0 +1,93 @@
# Copyright 2021 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 ipaddress
from openstack import exceptions as sdk_exc
from metalsmith import exceptions
def create_network_metadata(connection, attached_ports):
network_data = {}
if not attached_ports:
return network_data
links = network_data.setdefault('links', [])
networks = network_data.setdefault('networks', [])
services = network_data.setdefault('services', [])
for attached_port in attached_ports:
try:
port = connection.network.get_port(attached_port)
net = connection.network.get_network(port.network_id)
subnets = [connection.network.get_subnet(x['subnet_id'])
for x in port.fixed_ips]
subnets_by_id = {x.id: x for x in subnets}
except sdk_exc.SDKException as exc:
raise exceptions.NetworkResourceNotFound(
'Cannot find network resource: %s' % exc)
metadata_add_links(links, port, net)
metadata_add_services(services, subnets)
for idx, fixed_ip in enumerate(port.fixed_ips):
subnet = subnets_by_id[fixed_ip['subnet_id']]
metadata_add_network(networks, idx, fixed_ip, port, net, subnet)
return network_data
def metadata_add_links(links, port, network):
links.append({'id': port.id,
'type': 'phy',
'mtu': network.mtu,
'ethernet_mac_address': port.mac_address})
def metadata_add_services(services, subnets):
for subnet in subnets:
for dns_nameserver in subnet.dns_nameservers:
services.append({'type': 'dns',
'address': dns_nameserver})
def metadata_add_network(networks, idx, fixed_ip, port, network, subnet):
ip_net = ipaddress.ip_network(subnet.cidr)
net_data = {'id': network.name + str(idx),
'network_id': network.id,
'link': port.id,
'ip_address': fixed_ip['ip_address'],
'netmask': str(ip_net.netmask)}
if subnet.ip_version == 4:
net_data['type'] = 'ipv4_dhcp' if subnet.is_dhcp_enabled else 'ipv4'
elif subnet.ip_version == 6:
net_data['type'] = ('ipv6_{}'.format(subnet.ipv6_address_mode)
if subnet.ipv6_address_mode else 'ipv6')
net_routes = net_data.setdefault('routes', [])
for route in subnet.host_routes:
ip_net = ipaddress.ip_network(route['destination'])
net_routes.append({'network': str(ip_net.network_address),
'netmask': str(ip_net.netmask),
'gateway': route['nexthop']})
# Services go in both "network" and toplevel.
# Ref: https://docs.openstack.org/nova/latest/_downloads/9119ca7ac90aa2990e762c08baea3a36/network_data.json # noqa
net_services = net_data.setdefault('services', [])
metadata_add_services(net_services, [subnet])
networks.append(net_data)

View File

@ -19,6 +19,7 @@ from openstack import connection
from openstack import exceptions as os_exc
from metalsmith import _instance
from metalsmith import _network_metadata
from metalsmith import _nics
from metalsmith import _scheduler
from metalsmith import _utils
@ -427,9 +428,13 @@ class Provisioner(object):
node, instance_info=instance_info, extra=extra)
self.connection.baremetal.validate_node(node)
network_data = _network_metadata.create_network_metadata(
self.connection, node.extra.get(_ATTACHED_PORTS))
LOG.debug('Generating a configdrive for node %s',
_utils.log_res(node))
cd = config.generate(node, _utils.hostname_for(node, allocation))
cd = config.generate(node, _utils.hostname_for(node, allocation),
network_data)
LOG.debug('Starting provisioning of node %s', _utils.log_res(node))
self.connection.baremetal.set_node_provision_state(
node, 'active', config_drive=cd)

View File

@ -95,6 +95,10 @@ class InstanceNotFound(Error):
"""Instance not found or node doesn't have an instance associated."""
class NetworkResourceNotFound(Error):
"""Network resource, port, network, subnet not found"""
# Deprecated aliases
DeploymentFailure = DeploymentFailed
InvalidInstance = InstanceNotFound

View File

@ -46,15 +46,18 @@ class GenericConfig(object):
'got %r' % meta_data)
self.meta_data = meta_data or {}
def generate(self, node, hostname=None):
def generate(self, node, hostname=None, network_data=None):
"""Generate the config drive information.
:param node: `Node` object.
:param hostname: Desired hostname (defaults to node's name or ID).
:param network_data: Network metadata as dictionary
:return: configdrive contents as a dictionary with keys:
``meta_data``
meta data dictionary
``network_data``
network data as dictionary
``user_data``
user data as a string
"""
@ -81,8 +84,12 @@ class GenericConfig(object):
user_data = self.populate_user_data()
return {'meta_data': meta_data,
'user_data': user_data}
data = {'meta_data': meta_data, 'user_data': user_data}
if network_data:
data['network_data'] = network_data
return data
def populate_user_data(self):
"""Get user data for this configuration.

View File

@ -29,7 +29,8 @@ class TestGenericConfig(unittest.TestCase):
self.node.name = 'node name'
def _check(self, config, expected_metadata, expected_userdata=None,
cloud_init=True, hostname=None):
cloud_init=True, hostname=None, network_data=None,
expected_network_data=None):
expected_m = {'public_keys': {},
'uuid': self.node.id,
'name': self.node.name,
@ -40,7 +41,7 @@ class TestGenericConfig(unittest.TestCase):
'meta': {}}
expected_m.update(expected_metadata)
result = config.generate(self.node, hostname)
result = config.generate(self.node, hostname, network_data)
self.assertEqual(expected_m, result['meta_data'])
user_data = result['user_data']
@ -52,6 +53,11 @@ class TestGenericConfig(unittest.TestCase):
user_data = json.loads(user_data)
self.assertEqual(expected_userdata, user_data)
network_data = result.get('network_data')
if expected_network_data:
self.assertIsNotNone(network_data)
self.assertEqual(expected_network_data, network_data)
def test_default(self):
config = self.CLASS()
self._check(config, {})
@ -85,6 +91,11 @@ class TestGenericConfig(unittest.TestCase):
def test_custom_metadata_not_dict(self):
self.assertRaises(TypeError, self.CLASS, meta_data="foobar")
def test_custom_network_data(self):
config = self.CLASS()
data = {'net': 'data'}
self._check(config, {}, network_data=data, expected_network_data=data)
class TestCloudInitConfig(TestGenericConfig):
CLASS = instance_config.CloudInitConfig

View File

@ -0,0 +1,130 @@
# Copyright 2021 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 unittest
from unittest import mock
from metalsmith import _network_metadata
class TestMetadataAdd(unittest.TestCase):
def test_metadata_add_links(self):
port = mock.Mock()
network = mock.Mock()
port.id = 'port_id'
port.mac_address = 'aa:bb:cc:dd:ee:ff'
network.mtu = 1500
links = []
expected = [{'id': 'port_id',
'type': 'phy',
'mtu': 1500,
'ethernet_mac_address': 'aa:bb:cc:dd:ee:ff'}]
_network_metadata.metadata_add_links(links, port, network)
self.assertEqual(expected, links)
def test_metadata_add_services(self):
subnet_a = mock.Mock()
subnet_b = mock.Mock()
subnet_a.dns_nameservers = ['192.0.2.1', '192.0.2.2']
subnet_b.dns_nameservers = ['192.0.2.11', '192.0.2.22']
subnets = [subnet_a, subnet_b]
services = []
expected = [{'address': '192.0.2.1', 'type': 'dns'},
{'address': '192.0.2.2', 'type': 'dns'},
{'address': '192.0.2.11', 'type': 'dns'},
{'address': '192.0.2.22', 'type': 'dns'}]
_network_metadata.metadata_add_services(services, subnets)
self.assertEqual(expected, services)
def test_metadata_add_network_ipv4_dhcp(self):
idx = 1
fixed_ip = {'ip_address': '192.0.2.100', 'subnet_id': 'subnet_id'}
port = mock.Mock()
port.id = 'port_id'
subnet = mock.Mock()
subnet.cidr = '192.0.2.0/26'
subnet.ip_version = 4
subnet.is_dhcp_enabled = True
subnet.host_routes = [
{'destination': '192.0.2.64/26', 'nexthop': '192.0.2.1'},
{'destination': '192.0.2.128/26', 'nexthop': '192.0.2.1'}
]
subnet.dns_nameservers = ['192.0.2.11', '192.0.2.22']
network = mock.Mock()
network.id = 'network_id'
network.name = 'net_name'
networks = []
expected = [{'id': 'net_name1',
'ip_address': '192.0.2.100',
'link': 'port_id',
'netmask': '255.255.255.192',
'network_id': 'network_id',
'routes': [{'gateway': '192.0.2.1',
'netmask': '255.255.255.192',
'network': '192.0.2.64'},
{'gateway': '192.0.2.1',
'netmask': '255.255.255.192',
'network': '192.0.2.128'}],
'services': [{'address': '192.0.2.11', 'type': 'dns'},
{'address': '192.0.2.22', 'type': 'dns'}],
'type': 'ipv4_dhcp'}]
_network_metadata.metadata_add_network(networks, idx, fixed_ip, port,
network, subnet)
self.assertEqual(expected, networks)
def test_metadata_add_network_ipv6_stateful(self):
idx = 1
fixed_ip = {'ip_address': '2001:db8:1::10', 'subnet_id': 'subnet_id'}
port = mock.Mock()
port.id = 'port_id'
subnet = mock.Mock()
subnet.cidr = '2001:db8:1::/64'
subnet.ip_version = 6
subnet.ipv6_address_mode = 'dhcpv6-stateful'
subnet.host_routes = [
{'destination': '2001:db8:2::/64', 'nexthop': '2001:db8:1::1'},
{'destination': '2001:db8:3::/64', 'nexthop': '2001:db8:1::1'}
]
subnet.dns_nameservers = ['2001:db8:1::ee', '2001:db8:2::ff']
network = mock.Mock()
network.id = 'network_id'
network.name = 'net_name'
networks = []
expected = [
{'id': 'net_name1',
'ip_address': '2001:db8:1::10',
'link': 'port_id',
'netmask': 'ffff:ffff:ffff:ffff::',
'network_id': 'network_id',
'routes': [{'gateway': '2001:db8:1::1',
'netmask': 'ffff:ffff:ffff:ffff::',
'network': '2001:db8:2::'},
{'gateway': '2001:db8:1::1',
'netmask': 'ffff:ffff:ffff:ffff::',
'network': '2001:db8:3::'}],
'services': [{'address': '2001:db8:1::ee', 'type': 'dns'},
{'address': '2001:db8:2::ff', 'type': 'dns'}],
'type': 'ipv6_dhcpv6-stateful'}]
_network_metadata.metadata_add_network(networks, idx, fixed_ip, port,
network, subnet)
self.assertEqual(expected, networks)

View File

@ -20,6 +20,7 @@ from openstack import exceptions as os_exc
import requests
from metalsmith import _instance
from metalsmith import _network_metadata
from metalsmith import _provisioner
from metalsmith import _utils
from metalsmith import exceptions
@ -510,6 +511,12 @@ class TestProvisionNode(Base):
self.configdrive_mock = configdrive_patcher.start()
self.addCleanup(configdrive_patcher.stop)
create_network_metadata_patches = mock.patch.object(
_network_metadata, 'create_network_metadata', autospec=True
)
self.network_metadata_mock = create_network_metadata_patches.start()
self.addCleanup(create_network_metadata_patches.stop)
self.api.baremetal.get_node.side_effect = lambda _n: self.node
self.api.baremetal.get_allocation.side_effect = (
lambda _a: self.allocation)
@ -531,7 +538,8 @@ class TestProvisionNode(Base):
self.node, instance_info=self.instance_info, extra=self.extra)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
self.allocation.name)
self.allocation.name,
mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(self.api.network.delete_port.called)
@ -553,7 +561,7 @@ class TestProvisionNode(Base):
self.node, instance_info=self.instance_info, extra=self.extra)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
self.node.name)
self.node.name, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(self.api.network.delete_port.called)
@ -610,7 +618,7 @@ class TestProvisionNode(Base):
self.assertEqual(inst.node, self.node)
config.generate.assert_called_once_with(self.node,
self.allocation.name)
self.allocation.name, mock.ANY)
self.api.network.create_port.assert_called_once_with(
network_id=self.api.network.find_network.return_value.id,
name='example.com-%s' %
@ -660,7 +668,7 @@ class TestProvisionNode(Base):
self.node, instance_info=self.instance_info, extra=self.extra)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
hostname)
hostname, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(
@ -688,7 +696,7 @@ class TestProvisionNode(Base):
self.node, instance_info=self.instance_info, extra=self.extra)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
hostname)
hostname, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(
@ -718,7 +726,7 @@ class TestProvisionNode(Base):
self.node, instance_info=self.instance_info, extra=self.extra)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
hostname)
hostname, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(
@ -775,7 +783,7 @@ class TestProvisionNode(Base):
self.node, extra=self.extra, instance_info=self.instance_info)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
self.node.name)
self.node.name, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(
@ -808,7 +816,7 @@ class TestProvisionNode(Base):
self.node, extra=self.extra, instance_info=self.instance_info)
self.api.baremetal.validate_node.assert_called_once_with(self.node)
self.configdrive_mock.assert_called_once_with(mock.ANY, self.node,
self.node.id)
self.node.id, mock.ANY)
self.api.baremetal.set_node_provision_state.assert_called_once_with(
self.node, 'active', config_drive=mock.ANY)
self.assertFalse(

View File

@ -0,0 +1,13 @@
---
features:
- |
Network metadata is now created and written to the instance config in the
config-drive for deployed nodes.
fixes:
- |
Fixed and issue where deployed nodes did not become available over the
network. This happened when the first network interface was not connected
to a network with a DHCP service, i.e a secondary network interface was
used. The addition of network metadata in the instance config solves this
problem. See bug:
`2009238 <https://storyboard.openstack.org/#!/story/2009238>`_.