Merge ~blake-rouse/maas:clone-interfaces into maas:master

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: 0b1637898334806f396f8daea037c5c92a75d958
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~blake-rouse/maas:clone-interfaces
Merge into: maas:master
Diff against target: 532 lines (+446/-3)
4 files modified
src/maasserver/models/node.py (+146/-0)
src/maasserver/models/tests/test_node.py (+272/-0)
src/maasserver/testing/factory.py (+7/-2)
src/maasserver/tests/test_preseed_network.py (+21/-1)
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
MAAS Lander Approve
Review via email: mp+363272@code.launchpad.net

Commit message

Add ability to clone interfaces from a source node to a destination node.

Uses similar logic to how cloning the storage layout works. Each clone occurs in a layer until the parent layer is cloned, only then can the child layer be cloned. This must take special care to be ensure that the MAC addresses are from the destination node and not MAC address from the source node.

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b clone-interfaces lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: d9fa8688a26b16f647f85793f4fa9df7bded1e0d

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Looks like a good start; see one comment below about an existing method that may be helpful to yield interfaces in a parents-first order.

What about subnet links? Should we create similar ones, such as AUTO for AUTO and DHCP for DHCP? If a static IP exists, should we try to assign one on the same subnet? (I wasn't sure if that was left out on purpose and/or planned for a later branch, so that makes this branch "Needs Information".)

Nice job on the unit tests. Should we add a test for something even more complex, such as a bridge on a VLAN on a bond?

review: Needs Information
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Ah, I missed the part that tries to allocate a new IP address.

Have you tested that this correctly handles all types of subnet links? What about AUTO addresses on a DEPLOYED node that have been assigned, vs. an AUTO address on a READY node?

~blake-rouse/maas:clone-interfaces updated
0b16378... by Blake Rouse

Be more deligent on the cloning of IP address. Add unit tests for better coverage of IP address cloning.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Thanks for the review.

I added more tests to cover the IP address cloning. The code also need to be improved to handle more cases with regards to IP address cloning. So thanks for pointing that out.

I didn't the same level of complexity for interface configuration as the preseed generation does. I don't think we need to expand it here, when that seems to be providing good coverage in the preseed generation.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

FWIW, this branch nor the storage were attached to this: https://bugs.launchpad.net/maas/+bug/1814901

Please make sure this is the case.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

I do not want the branch attached yet, or the lander will mark the bug fixed. This is just steps to fix the bug. The final API branch will link to the bug.

Once this lands I will link the branch.

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b clone-interfaces lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 0b1637898334806f396f8daea037c5c92a75d958

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Looks good. Thanks for cleaning up the IP allocation code.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py
2index 95f3353..9a680f0 100644
3--- a/src/maasserver/models/node.py
4+++ b/src/maasserver/models/node.py
5@@ -114,6 +114,7 @@ from maasserver.models.interface import (
6 BondInterface,
7 BridgeInterface,
8 Interface,
9+ InterfaceRelationship,
10 PhysicalInterface,
11 VLANInterface,
12 )
13@@ -3665,6 +3666,151 @@ class Node(CleanSave, TimestampedModel):
14 if interface.enabled:
15 interface.ensure_link_up()
16
17+ def set_networking_configuration_from_node(self, source_node):
18+ """Set the networking configuration for this node from the source
19+ node."""
20+ mapping = self._get_interface_mapping_between_nodes(source_node)
21+ self._clear_networking_configuration()
22+
23+ # Get all none physical interface for the source node. This
24+ # is used to do the cloning in layers, only cloning the interfaces
25+ # that have already been cloned to make up the next layer of
26+ # interfaces.
27+ source_interfaces = [
28+ interface
29+ for interface in source_node.interface_set.all()
30+ if interface.type != INTERFACE_TYPE.PHYSICAL
31+ ]
32+
33+ # Clone the model at the physical level.
34+ exclude_addresses = self._copy_between_interface_mappings(mapping)
35+ mapping = {
36+ source_interface.id: self_interface
37+ for self_interface, source_interface in mapping.items()
38+ }
39+
40+ # Continue through each layer until no more interfaces exist.
41+ source_interfaces, layer_interfaces = (
42+ self._get_interface_layers_for_copy(source_interfaces, mapping))
43+ while source_interfaces or layer_interfaces:
44+ if not layer_interfaces:
45+ raise ValueError(
46+ "Copying the next layer of interfaces has failed.")
47+ for source_interface, dest_parents in layer_interfaces.items():
48+ dest_mapping = self._copy_interface(
49+ source_interface, dest_parents)
50+ exclude_addresses += (
51+ self._copy_between_interface_mappings(
52+ dest_mapping, exclude_addresses=exclude_addresses))
53+ mapping.update({
54+ source_interface.id: self_interface
55+ for self_interface, source_interface in (
56+ dest_mapping.items())
57+ })
58+ # Load the next layer.
59+ source_interfaces, layer_interfaces = (
60+ self._get_interface_layers_for_copy(
61+ source_interfaces, mapping))
62+
63+ def _get_interface_mapping_between_nodes(self, source_node):
64+ """Return the mapping between which interface from this node map to
65+ interfaces on the source node.
66+
67+ Mapping between the nodes is done by the name of the interface. This
68+ node must have the same names as the names from `source_node`.
69+
70+ Raises a `ValidationError` when interfaces cannot be matched.
71+ """
72+ self_interfaces = {
73+ interface.name: interface
74+ for interface in self.interface_set.all()
75+ if interface.type == INTERFACE_TYPE.PHYSICAL
76+ }
77+ missing = []
78+ mapping = {}
79+ for interface in source_node.interface_set.all():
80+ if interface.type == INTERFACE_TYPE.PHYSICAL:
81+ self_interface = self_interfaces.get(interface.name, None)
82+ if self_interface is not None:
83+ mapping[self_interface] = interface
84+ else:
85+ missing.append(interface.name)
86+ if missing:
87+ raise ValidationError(
88+ "destination node physical interfaces do not match the "
89+ "source nodes physical interfaces: %s" % ', '.join(missing))
90+ return mapping
91+
92+ def _copy_between_interface_mappings(
93+ self, mapping, exclude_addresses=None):
94+ """Copy the source onto the destination interfaces in the mapping."""
95+ if exclude_addresses is None:
96+ exclude_addresses = []
97+ for self_interface, source_interface in mapping.items():
98+ self_interface.vlan = source_interface.vlan
99+ self_interface.params = source_interface.params
100+ self_interface.ipv4_params = source_interface.ipv4_params
101+ self_interface.ipv6_params = source_interface.ipv6_params
102+ self_interface.enabled = source_interface.enabled
103+ self_interface.acquired = source_interface.acquired
104+ self_interface.save()
105+
106+ for ip_address in source_interface.ip_addresses.all():
107+ if ip_address.ip and ip_address.alloc_type in [
108+ IPADDRESS_TYPE.AUTO, IPADDRESS_TYPE.STICKY,
109+ IPADDRESS_TYPE.USER_RESERVED]:
110+ new_ip = StaticIPAddress.objects.allocate_new(
111+ subnet=ip_address.subnet,
112+ alloc_type=ip_address.alloc_type,
113+ user=ip_address.user,
114+ exclude_addresses=exclude_addresses)
115+ self_interface.ip_addresses.add(new_ip)
116+ exclude_addresses.append(new_ip.id)
117+ elif ip_address.alloc_type != IPADDRESS_TYPE.DISCOVERED:
118+ self_ip_address = copy.deepcopy(ip_address)
119+ self_ip_address.id = None
120+ self_ip_address.pk = None
121+ self_ip_address.ip = None
122+ self_ip_address.save(force_insert=True)
123+ self_interface.ip_addresses.add(self_ip_address)
124+ return exclude_addresses
125+
126+ def _get_interface_layers_for_copy(
127+ self, source_interfaces, interface_mapping):
128+ """Pops the interface from the `source_interfaces` when all interfaces
129+ that make it up exist in `interface_mapping`."""
130+ layer = {}
131+ for interface in source_interfaces[:]: # Iterate on copy
132+ contains_all = True
133+ dest_parents = []
134+ for parent_interface in interface.parents.all():
135+ dest_interface = interface_mapping.get(
136+ parent_interface.id, None)
137+ if dest_interface:
138+ dest_parents.append(dest_interface)
139+ else:
140+ contains_all = False
141+ break
142+ if contains_all:
143+ layer[interface] = dest_parents
144+ source_interfaces.remove(interface)
145+ return source_interfaces, layer
146+
147+ def _copy_interface(self, source_interface, dest_parents):
148+ """Copy the `source_interface` linking to the `dest_parents`."""
149+ self_interface = copy.copy(source_interface)
150+ self_interface.id = None
151+ self_interface.pk = None
152+ self_interface.node = self
153+ self_interface._prefetched_objects_cache = {}
154+ self_interface.save(force_insert=True)
155+ for parent in dest_parents:
156+ InterfaceRelationship.objects.create(
157+ child=self_interface, parent=parent)
158+ self_interface.mac_address = dest_parents[0].mac_address
159+ self_interface.save()
160+ return {self_interface: source_interface}
161+
162 def get_gateways_by_priority(self):
163 """Return all possible default gateways for the Node, by priority.
164
165diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py
166index 93e78e7..ca0dac3 100644
167--- a/src/maasserver/models/tests/test_node.py
168+++ b/src/maasserver/models/tests/test_node.py
169@@ -130,6 +130,7 @@ from maasserver.node_status import (
170 )
171 from maasserver.permissions import NodePermission
172 from maasserver.preseed import CURTIN_INSTALL_LOG
173+from maasserver.preseed_network import compose_curtin_network_config
174 from maasserver.preseed_storage import compose_curtin_storage_config
175 from maasserver.rbac import (
176 FakeRBACClient,
177@@ -153,6 +154,7 @@ from maasserver.testing.testcase import (
178 MAASServerTestCase,
179 MAASTransactionServerTestCase,
180 )
181+from maasserver.tests.test_preseed_network import AssertNetworkConfigMixin
182 from maasserver.tests.test_preseed_storage import AssertStorageConfigMixin
183 from maasserver.utils.orm import (
184 get_one,
185@@ -244,6 +246,7 @@ from testtools.matchers import (
186 )
187 from twisted.internet import defer
188 from twisted.internet.error import ConnectionDone
189+import yaml
190
191
192 wait_for_reactor = wait_for(30) # 30 seconds.
193@@ -11914,3 +11917,272 @@ class TestNodeStorageClone_SpecialFilesystems(
194 self.assertStorageConfig(
195 self.STORAGE_CONFIG, compose_curtin_storage_config(dest_node),
196 strip_uuids=True)
197+
198+
199+class TestNodeInterfaceClone__MappingBetweenNodes(MAASServerTestCase):
200+
201+ def test__match_by_name(self):
202+ node1 = factory.make_Node()
203+ node1_eth0 = factory.make_Interface(node=node1, name='eth0')
204+ node1_ens3 = factory.make_Interface(node=node1, name='ens3')
205+ node1_br0 = factory.make_Interface(node=node1, name='br0')
206+ node2 = factory.make_Node()
207+ node2_eth0 = factory.make_Interface(node=node2, name='eth0')
208+ node2_ens3 = factory.make_Interface(node=node2, name='ens3')
209+ node2_br0 = factory.make_Interface(node=node2, name='br0')
210+ factory.make_Interface(node=node2, name='other')
211+ self.assertEqual({
212+ node2_eth0: node1_eth0,
213+ node2_ens3: node1_ens3,
214+ node2_br0: node1_br0,
215+ }, node2._get_interface_mapping_between_nodes(node1))
216+
217+ def test__fail_when_source_no_match(self):
218+ node1 = factory.make_Node()
219+ factory.make_Interface(node=node1, name='eth0')
220+ factory.make_Interface(node=node1, name='ens3')
221+ factory.make_Interface(node=node1, name='br0')
222+ factory.make_Interface(node=node1, name='other')
223+ factory.make_Interface(node=node1, name='match')
224+ node2 = factory.make_Node()
225+ factory.make_Interface(node=node2, name='eth0')
226+ factory.make_Interface(node=node2, name='ens3')
227+ factory.make_Interface(node=node2, name='br0')
228+ error = self.assertRaises(
229+ ValidationError, node2._get_interface_mapping_between_nodes, node1)
230+ self.assertEquals(
231+ 'destination node physical interfaces do not match the '
232+ 'source nodes physical interfaces: other, match', error.message)
233+
234+
235+class TestNodeInterfaceClone__IPCloning(MAASServerTestCase):
236+
237+ def test__auto_ip_assigned_on_clone_when_source_has_ip(self):
238+ node = factory.make_Node()
239+ node_eth0 = factory.make_Interface(node=node, name='eth0')
240+ node_ip = factory.make_StaticIPAddress(
241+ alloc_type=IPADDRESS_TYPE.AUTO, interface=node_eth0)
242+ self.assertIsNotNone(node_ip.ip)
243+
244+ dest_node = factory.make_Node()
245+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
246+ dest_node.set_networking_configuration_from_node(node)
247+ dest_ip = dest_node_eth0.ip_addresses.first()
248+ self.assertIsNotNone(dest_ip.ip)
249+ self.assertNotEqual(node_ip.ip, dest_ip.ip)
250+
251+ def test__auto_ip_unassigned_on_clone_when_source_has_no_ip(self):
252+ node = factory.make_Node()
253+ node_eth0 = factory.make_Interface(node=node, name='eth0')
254+ node_ip = factory.make_StaticIPAddress(
255+ alloc_type=IPADDRESS_TYPE.AUTO, interface=node_eth0, ip=None)
256+ self.assertIsNone(node_ip.ip)
257+
258+ dest_node = factory.make_Node()
259+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
260+ dest_node.set_networking_configuration_from_node(node)
261+ dest_ip = dest_node_eth0.ip_addresses.first()
262+ self.assertIsNone(dest_ip.ip)
263+
264+ def test__sticky_ip_assigned_on_clone(self):
265+ node = factory.make_Node()
266+ node_eth0 = factory.make_Interface(node=node, name='eth0')
267+ node_ip = factory.make_StaticIPAddress(
268+ alloc_type=IPADDRESS_TYPE.STICKY, interface=node_eth0)
269+ self.assertIsNotNone(node_ip.ip)
270+
271+ dest_node = factory.make_Node()
272+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
273+ dest_node.set_networking_configuration_from_node(node)
274+ dest_ip = dest_node_eth0.ip_addresses.first()
275+ self.assertIsNotNone(dest_ip.ip)
276+ self.assertNotEqual(node_ip.ip, dest_ip.ip)
277+
278+ def test__user_reserved_ip_assigned_on_clone(self):
279+ user = factory.make_User()
280+ subnet = factory.make_Subnet()
281+ node = factory.make_Node()
282+ node_eth0 = factory.make_Interface(node=node, name='eth0')
283+ node_ip = factory.make_StaticIPAddress(
284+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, interface=node_eth0,
285+ user=user, subnet=subnet)
286+ self.assertIsNotNone(node_ip.ip)
287+
288+ dest_node = factory.make_Node()
289+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
290+ dest_node.set_networking_configuration_from_node(node)
291+ dest_ip = dest_node_eth0.ip_addresses.first()
292+ self.assertIsNotNone(dest_ip.ip)
293+ self.assertNotEqual(node_ip.ip, dest_ip.ip)
294+ self.assertEqual(user, dest_ip.user)
295+
296+ def test__dhcp_assigned_on_clone(self):
297+ node = factory.make_Node()
298+ node_eth0 = factory.make_Interface(node=node, name='eth0')
299+ factory.make_StaticIPAddress(
300+ alloc_type=IPADDRESS_TYPE.DHCP, interface=node_eth0, ip=None)
301+
302+ dest_node = factory.make_Node()
303+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
304+ dest_node.set_networking_configuration_from_node(node)
305+ dest_ip = dest_node_eth0.ip_addresses.first()
306+ self.assertEqual(IPADDRESS_TYPE.DHCP, dest_ip.alloc_type)
307+
308+ def test__discovered_not_assigned_on_clone(self):
309+ node = factory.make_Node()
310+ node_eth0 = factory.make_Interface(node=node, name='eth0')
311+ factory.make_StaticIPAddress(
312+ alloc_type=IPADDRESS_TYPE.DISCOVERED, interface=node_eth0)
313+
314+ dest_node = factory.make_Node()
315+ dest_node_eth0 = factory.make_Interface(node=dest_node, name='eth0')
316+ dest_node.set_networking_configuration_from_node(node)
317+ dest_ip = dest_node_eth0.ip_addresses.first()
318+ self.assertIsNone(dest_ip)
319+
320+
321+class TestNodeInterfaceClone_SimpleNetworkLayout(
322+ MAASServerTestCase, AssertNetworkConfigMixin):
323+
324+ def create_staticipaddresses(self, node):
325+ for iface in node.interface_set.filter(enabled=True):
326+ factory.make_StaticIPAddress(
327+ interface=iface,
328+ subnet=iface.vlan.subnet_set.first())
329+ iface.params = {
330+ "mtu": random.randint(600, 1400),
331+ "accept_ra": factory.pick_bool(),
332+ "autoconf": factory.pick_bool(),
333+ }
334+ iface.save()
335+ extra_interface = node.interface_set.all()[1]
336+ sip = factory.make_StaticIPAddress(
337+ alloc_type=IPADDRESS_TYPE.STICKY, ip="",
338+ subnet=None, interface=extra_interface)
339+ sip.subnet = None
340+ sip.save()
341+
342+ def test__copy(self):
343+ # Keep them in the same domain to make the checking of configuraton
344+ # easy. A copy to destination doesn't move the destinations nodes
345+ # domain.
346+ domain = factory.make_Domain('bbb')
347+ node = factory.make_Node_with_Interface_on_Subnet(
348+ interface_count=2, ifname='eth0', extra_ifnames=['eth1'],
349+ domain=domain)
350+ self.create_staticipaddresses(node)
351+ node_config = self.collect_interface_config(node)
352+ node_config += self.collect_dns_config(node)
353+
354+ dest_node = factory.make_Node_with_Interface_on_Subnet(
355+ interface_count=2, ifname='eth0', extra_ifnames=['eth1'],
356+ domain=domain)
357+ dest_node.set_networking_configuration_from_node(node)
358+ dest_config = self.collect_interface_config(dest_node)
359+ dest_config += self.collect_dns_config(dest_node)
360+
361+ node_composed_config = compose_curtin_network_config(node)
362+ dest_composed_config = compose_curtin_network_config(dest_node)
363+ self.assertNetworkConfig(
364+ node_config, dest_composed_config, strip_macs=True, strip_ips=True)
365+ self.assertNetworkConfig(
366+ dest_config, node_composed_config, strip_macs=True, strip_ips=True)
367+
368+
369+class TestNodeInterfaceClone_VLANOnBondNetworkLayout(
370+ MAASServerTestCase, AssertNetworkConfigMixin):
371+
372+ def test__copy(self):
373+ domain = factory.make_Domain('bbb')
374+ node = factory.make_Node_with_Interface_on_Subnet(
375+ interface_count=2, ifname='eth0', extra_ifnames=['eth1'],
376+ domain=domain)
377+ phys_ifaces = list(node.interface_set.all())
378+ phys_vlan = node.interface_set.first().vlan
379+ bond_iface = factory.make_Interface(iftype=INTERFACE_TYPE.BOND,
380+ node=node, vlan=phys_vlan,
381+ parents=phys_ifaces)
382+ bond_iface.params = {
383+ "bond_mode": "balance-rr",
384+ }
385+ bond_iface.save()
386+ vlan_iface = factory.make_Interface(
387+ iftype=INTERFACE_TYPE.VLAN, node=node, parents=[bond_iface])
388+ subnet = factory.make_Subnet(vlan=vlan_iface.vlan)
389+ factory.make_StaticIPAddress(interface=vlan_iface, subnet=subnet)
390+ node_config = self.collect_interface_config(node, filter="physical")
391+ node_config += self.collect_interface_config(node, filter="bond")
392+ node_config += self.collect_interface_config(node, filter="vlan")
393+ node_config += self.collect_dns_config(node)
394+
395+ dest_node = factory.make_Node_with_Interface_on_Subnet(
396+ interface_count=2, ifname='eth0', extra_ifnames=['eth1'],
397+ domain=domain)
398+ dest_node.set_networking_configuration_from_node(node)
399+ dest_config = self.collect_interface_config(
400+ dest_node, filter="physical")
401+ dest_config += self.collect_interface_config(dest_node, filter="bond")
402+ dest_config += self.collect_interface_config(dest_node, filter="vlan")
403+ dest_config += self.collect_dns_config(dest_node)
404+
405+ node_composed_config = compose_curtin_network_config(node)
406+ dest_composed_config = compose_curtin_network_config(dest_node)
407+ self.assertNetworkConfig(
408+ node_config, dest_composed_config, strip_macs=True, strip_ips=True)
409+ self.assertNetworkConfig(
410+ dest_config, node_composed_config, strip_macs=True, strip_ips=True)
411+
412+ # Bond configuration should have different MAC addresses.
413+ node_bond = yaml.safe_load(
414+ self.collect_interface_config(node, filter="bond"))
415+ dest_bond = yaml.safe_load(
416+ self.collect_interface_config(dest_node, filter="bond"))
417+ self.assertNotEqual(
418+ node_bond[0]['mac_address'], dest_bond[0]['mac_address'])
419+
420+
421+class TestNodeInterfaceClone_BridgeNetworkLayout(
422+ MAASServerTestCase, AssertNetworkConfigMixin):
423+
424+ def test__renders_expected_output(self):
425+ node = factory.make_Node_with_Interface_on_Subnet(ifname='eth0')
426+ boot_interface = node.get_boot_interface()
427+ vlan = boot_interface.vlan
428+ mac_address = factory.make_mac_address()
429+ bridge_iface = factory.make_Interface(
430+ iftype=INTERFACE_TYPE.BRIDGE, node=node, vlan=vlan,
431+ parents=[boot_interface], mac_address=mac_address)
432+ bridge_iface.params = {
433+ "bridge_fd": 0,
434+ "bridge_stp": True,
435+ }
436+ bridge_iface.save()
437+ factory.make_StaticIPAddress(
438+ interface=bridge_iface, alloc_type=IPADDRESS_TYPE.STICKY,
439+ subnet=bridge_iface.vlan.subnet_set.first())
440+ node_config = self.collect_interface_config(node, filter="physical")
441+ node_config += self.collect_interface_config(node, filter="bridge")
442+ node_config += self.collect_dns_config(node)
443+
444+ dest_node = factory.make_Node_with_Interface_on_Subnet(ifname='eth0')
445+ dest_node.set_networking_configuration_from_node(node)
446+ dest_config = self.collect_interface_config(
447+ dest_node, filter="physical")
448+ dest_config += self.collect_interface_config(
449+ dest_node, filter="bridge")
450+ dest_config += self.collect_dns_config(dest_node)
451+
452+ node_composed_config = compose_curtin_network_config(node)
453+ dest_composed_config = compose_curtin_network_config(dest_node)
454+ self.assertNetworkConfig(
455+ node_config, dest_composed_config, strip_macs=True, strip_ips=True)
456+ self.assertNetworkConfig(
457+ dest_config, node_composed_config, strip_macs=True, strip_ips=True)
458+
459+ # Bridge configuration should have different MAC addresses.
460+ node_bridge = yaml.safe_load(
461+ self.collect_interface_config(node, filter="bridge"))
462+ dest_bridge = yaml.safe_load(
463+ self.collect_interface_config(dest_node, filter="bridge"))
464+ self.assertNotEqual(
465+ node_bridge[0]['mac_address'], dest_bridge[0]['mac_address'])
466diff --git a/src/maasserver/testing/factory.py b/src/maasserver/testing/factory.py
467index e59a190..3671d61 100644
468--- a/src/maasserver/testing/factory.py
469+++ b/src/maasserver/testing/factory.py
470@@ -855,7 +855,8 @@ class Factory(maastesting.factory.Factory):
471
472 def make_Node_with_Interface_on_Subnet(
473 self, interface_count=1, vlan=None, subnet=None,
474- cidr=None, fabric=None, ifname=None, unmanaged=False,
475+ cidr=None, fabric=None, ifname=None, extra_ifnames=None,
476+ unmanaged=False,
477 with_dhcp_rack_primary=True, with_dhcp_rack_secondary=False,
478 primary_rack=None, secondary_rack=None,
479 **kwargs):
480@@ -908,8 +909,12 @@ class Factory(maastesting.factory.Factory):
481 alloc_type=IPADDRESS_TYPE.AUTO, ip="",
482 subnet=subnet, interface=boot_interface)
483 for _ in range(1, interface_count):
484+ ifname = None
485+ if extra_ifnames:
486+ ifname = extra_ifnames[0]
487+ extra_ifnames = extra_ifnames[1:]
488 interface = self.make_Interface(
489- INTERFACE_TYPE.PHYSICAL, node=node, vlan=vlan)
490+ INTERFACE_TYPE.PHYSICAL, name=ifname, node=node, vlan=vlan)
491 self.make_StaticIPAddress(
492 alloc_type=IPADDRESS_TYPE.DISCOVERED, ip="",
493 subnet=subnet, interface=interface)
494diff --git a/src/maasserver/tests/test_preseed_network.py b/src/maasserver/tests/test_preseed_network.py
495index 714ba49..a15cd34 100644
496--- a/src/maasserver/tests/test_preseed_network.py
497+++ b/src/maasserver/tests/test_preseed_network.py
498@@ -87,7 +87,21 @@ class AssertNetworkConfigMixin:
499 metric: %(metric)s
500 """)
501
502- def assertNetworkConfig(self, expected, output):
503+ def stripMACs(self, config):
504+ for entry in config:
505+ if 'mac_address' in entry:
506+ entry['mac_address'] = '*match*'
507+ return config
508+
509+ def stripIPs(self, config):
510+ for entry in config:
511+ for subnet in entry.get('subnets', []):
512+ if 'address' in subnet:
513+ subnet['address'] = '*match*'
514+ return config
515+
516+ def assertNetworkConfig(
517+ self, expected, output, strip_macs=False, strip_ips=False):
518 output = output[0]
519 output = yaml.safe_load(output)
520 self.assertThat(output, ContainsDict({
521@@ -101,6 +115,12 @@ class AssertNetworkConfigMixin:
522 }))
523 expected_network = yaml.safe_load(expected)
524 output_network = output["network"]["config"]
525+ if strip_macs:
526+ expected_network = self.stripMACs(expected_network)
527+ output_network = self.stripMACs(output_network)
528+ if strip_ips:
529+ expected_network = self.stripIPs(expected_network)
530+ output_network = self.stripIPs(output_network)
531 expected_equals = list(map(Equals, expected_network))
532 self.assertThat(output_network, MatchesListwise(expected_equals))
533

Subscribers

People subscribed via source and target branches