Merge ~blake-rouse/maas:clone-interfaces into maas:master
- Git
- lp:~blake-rouse/maas
- clone-interfaces
- Merge into master
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) |
Related bugs: |
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.
Description of the change
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?
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?
- 0b16378... by Blake Rouse
-
Be more deligent on the cloning of IP address. Add unit tests for better coverage of IP address cloning.
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.
Andres Rodriguez (andreserl) wrote : | # |
FWIW, this branch nor the storage were attached to this: https:/
Please make sure this is the case.
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.
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: 0b1637898334806
Mike Pontillo (mpontillo) wrote : | # |
Looks good. Thanks for cleaning up the IP allocation code.
Preview Diff
1 | diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py |
2 | index 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 | |
165 | diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py |
166 | index 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']) |
466 | diff --git a/src/maasserver/testing/factory.py b/src/maasserver/testing/factory.py |
467 | index 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) |
494 | diff --git a/src/maasserver/tests/test_preseed_network.py b/src/maasserver/tests/test_preseed_network.py |
495 | index 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 |
UNIT TESTS
-b clone-interfaces lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: SUCCESS 647f85793f4fa9d f7bded1e0d
COMMIT: d9fa8688a26b16f