Merge lp:~blake-rouse/maas/powernv-support-1.5 into lp:maas/1.5
- powernv-support-1.5
- Merge into 1.5
Proposed by
Blake Rouse
Status: | Merged |
---|---|
Approved by: | Blake Rouse |
Approved revision: | no longer in the source branch. |
Merged at revision: | 2284 |
Proposed branch: | lp:~blake-rouse/maas/powernv-support-1.5 |
Merge into: | lp:maas/1.5 |
Diff against target: |
746 lines (+612/-12) 10 files modified
etc/maas/templates/dhcp/dhcpd.conf.template (+1/-0) etc/maas/templates/pxe/config.commissioning.ppc64el.template (+6/-0) etc/maas/templates/pxe/config.install.ppc64el.template (+6/-0) src/provisioningserver/boot/__init__.py (+6/-0) src/provisioningserver/boot/powernv.py (+158/-0) src/provisioningserver/boot/tests/test_powernv.py (+337/-0) src/provisioningserver/dhcp/config.py (+24/-11) src/provisioningserver/driver/__init__.py (+8/-1) src/provisioningserver/utils/__init__.py (+21/-0) src/provisioningserver/utils/tests/test_utils.py (+45/-0) |
To merge this branch: | bzr merge lp:~blake-rouse/maas/powernv-support-1.5 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Blake Rouse (community) | Approve | ||
Review via email: mp+221782@code.launchpad.net |
Commit message
Support enlisting and booting PowerNV nodes. Added support for the path-prefix for dhcpd config architectures.
Description of the change
To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'etc/maas/templates/dhcp/dhcpd.conf.template' |
2 | --- etc/maas/templates/dhcp/dhcpd.conf.template 2014-04-11 05:21:15 +0000 |
3 | +++ etc/maas/templates/dhcp/dhcpd.conf.template 2014-06-02 20:19:07 +0000 |
4 | @@ -6,6 +6,7 @@ |
5 | # the nodegroup's configuration in MAAS to trigger an update. |
6 | |
7 | option arch code 93 = unsigned integer 16; # RFC4578 |
8 | +option path-prefix code 210 = text; #RFC5071 |
9 | {{for dhcp_subnet in dhcp_subnets}} |
10 | subnet {{dhcp_subnet['subnet']}} netmask {{dhcp_subnet['subnet_mask']}} { |
11 | {{bootloader}} |
12 | |
13 | === added file 'etc/maas/templates/pxe/config.commissioning.ppc64el.template' |
14 | --- etc/maas/templates/pxe/config.commissioning.ppc64el.template 1970-01-01 00:00:00 +0000 |
15 | +++ etc/maas/templates/pxe/config.commissioning.ppc64el.template 2014-06-02 20:19:07 +0000 |
16 | @@ -0,0 +1,6 @@ |
17 | +DEFAULT execute |
18 | + |
19 | +LABEL execute |
20 | + KERNEL {{kernel_params | kernel_path }} |
21 | + INITRD {{kernel_params | initrd_path }} |
22 | + APPEND {{kernel_params | kernel_command}} |
23 | |
24 | === added file 'etc/maas/templates/pxe/config.install.ppc64el.template' |
25 | --- etc/maas/templates/pxe/config.install.ppc64el.template 1970-01-01 00:00:00 +0000 |
26 | +++ etc/maas/templates/pxe/config.install.ppc64el.template 2014-06-02 20:19:07 +0000 |
27 | @@ -0,0 +1,6 @@ |
28 | +DEFAULT execute |
29 | + |
30 | +LABEL execute |
31 | + KERNEL {{kernel_params | kernel_path }} |
32 | + INITRD {{kernel_params | initrd_path }} |
33 | + APPEND {{kernel_params | kernel_command}} |
34 | |
35 | === added symlink 'etc/maas/templates/pxe/config.xinstall.ppc64el.template' |
36 | === target is u'config.install.ppc64el.template' |
37 | === modified file 'src/provisioningserver/boot/__init__.py' |
38 | --- src/provisioningserver/boot/__init__.py 2014-05-28 13:34:14 +0000 |
39 | +++ src/provisioningserver/boot/__init__.py 2014-06-02 20:19:07 +0000 |
40 | @@ -99,6 +99,10 @@ |
41 | |
42 | __metaclass__ = ABCMeta |
43 | |
44 | + # Path prefix that is used for the pxelinux.cfg. Used for |
45 | + # the dhcpd.conf that is generated. |
46 | + path_prefix = None |
47 | + |
48 | @abstractproperty |
49 | def name(self): |
50 | """Name of the boot method.""" |
51 | @@ -223,12 +227,14 @@ |
52 | from provisioningserver.boot.pxe import PXEBootMethod |
53 | from provisioningserver.boot.uefi import UEFIBootMethod |
54 | from provisioningserver.boot.powerkvm import PowerKVMBootMethod |
55 | +from provisioningserver.boot.powernv import PowerNVBootMethod |
56 | |
57 | |
58 | builtin_boot_methods = [ |
59 | PXEBootMethod(), |
60 | UEFIBootMethod(), |
61 | PowerKVMBootMethod(), |
62 | + PowerNVBootMethod(), |
63 | ] |
64 | for method in builtin_boot_methods: |
65 | BootMethodRegistry.register_item(method.name, method) |
66 | |
67 | === added file 'src/provisioningserver/boot/powernv.py' |
68 | --- src/provisioningserver/boot/powernv.py 1970-01-01 00:00:00 +0000 |
69 | +++ src/provisioningserver/boot/powernv.py 2014-06-02 20:19:07 +0000 |
70 | @@ -0,0 +1,158 @@ |
71 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
72 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
73 | + |
74 | +"""PowerNV Boot Method""" |
75 | + |
76 | +from __future__ import ( |
77 | + absolute_import, |
78 | + print_function, |
79 | + unicode_literals, |
80 | + ) |
81 | + |
82 | +str = None |
83 | + |
84 | +__metaclass__ = type |
85 | +__all__ = [ |
86 | + 'PowerNVBootMethod', |
87 | + ] |
88 | + |
89 | +import re |
90 | + |
91 | +from provisioningserver.boot import ( |
92 | + BootMethod, |
93 | + BytesReader, |
94 | + get_parameters, |
95 | + ) |
96 | +from provisioningserver.boot.pxe import ( |
97 | + ARP_HTYPE, |
98 | + re_mac_address, |
99 | + ) |
100 | +from provisioningserver.kernel_opts import compose_kernel_command_line |
101 | +from provisioningserver.utils import find_mac_via_arp |
102 | +from tftp.backend import FilesystemReader |
103 | +from twisted.python.context import get |
104 | + |
105 | +# The pxelinux.cfg path is prefixed with the architecture for the |
106 | +# PowerNV nodes. This prefix is set by the path-prefix dhcpd option. |
107 | +# We assume that the ARP HTYPE (hardware type) that PXELINUX sends is |
108 | +# always Ethernet. |
109 | +re_config_file = r''' |
110 | + # Optional leading slash(es). |
111 | + ^/* |
112 | + ppc64el # PowerNV pxe prefix, set by dhcpd |
113 | + / |
114 | + pxelinux[.]cfg # PXELINUX expects this. |
115 | + / |
116 | + (?: # either a MAC |
117 | + {htype:02x} # ARP HTYPE. |
118 | + - |
119 | + (?P<mac>{re_mac_address.pattern}) # Capture MAC. |
120 | + | # or "default" |
121 | + default |
122 | + ) |
123 | + $ |
124 | +''' |
125 | + |
126 | +re_config_file = re_config_file.format( |
127 | + htype=ARP_HTYPE.ETHERNET, re_mac_address=re_mac_address) |
128 | +re_config_file = re.compile(re_config_file, re.VERBOSE) |
129 | + |
130 | + |
131 | +def format_bootif(mac): |
132 | + """Formats a mac address into the BOOTIF format, expected by |
133 | + the linux kernel.""" |
134 | + mac = mac.replace(':', '-') |
135 | + mac = mac.upper() |
136 | + return '%02x-%s' % (ARP_HTYPE.ETHERNET, mac) |
137 | + |
138 | + |
139 | +class PowerNVBootMethod(BootMethod): |
140 | + |
141 | + name = "powernv" |
142 | + template_subdir = "pxe" |
143 | + bootloader_path = "pxelinux.0" |
144 | + arch_octet = "00:0E" |
145 | + path_prefix = "ppc64el/" |
146 | + |
147 | + def get_remote_mac(self): |
148 | + """Gets the requestors MAC address from arp cache. |
149 | + |
150 | + This is used, when the pxelinux.cfg is requested without the mac |
151 | + address appended. This is needed to inject the BOOTIF into the |
152 | + pxelinux.cfg that is returned to the node. |
153 | + """ |
154 | + remote_host, remote_port = get("remote", (None, None)) |
155 | + return find_mac_via_arp(remote_host) |
156 | + |
157 | + def get_params(self, backend, path): |
158 | + """Gets the matching parameters from the requested path.""" |
159 | + match = re_config_file.match(path) |
160 | + if match is not None: |
161 | + return get_parameters(match) |
162 | + if path.lstrip('/').startswith(self.path_prefix): |
163 | + return {'path': path} |
164 | + return None |
165 | + |
166 | + def match_path(self, backend, path): |
167 | + """Checks path for the configuration file that needs to be |
168 | + generated. |
169 | + |
170 | + :param backend: requesting backend |
171 | + :param path: requested path |
172 | + :returns: dict of match params from path, None if no match |
173 | + """ |
174 | + params = self.get_params(backend, path) |
175 | + if params is None: |
176 | + return None |
177 | + params['arch'] = "ppc64el" |
178 | + if 'mac' not in params: |
179 | + mac = self.get_remote_mac() |
180 | + if mac is not None: |
181 | + params['mac'] = mac |
182 | + return params |
183 | + |
184 | + def get_reader(self, backend, kernel_params, **extra): |
185 | + """Render a configuration file as a unicode string. |
186 | + |
187 | + :param backend: requesting backend |
188 | + :param kernel_params: An instance of `KernelParameters`. |
189 | + :param extra: Allow for other arguments. This is a safety valve; |
190 | + parameters generated in another component (for example, see |
191 | + `TFTPBackend.get_config_reader`) won't cause this to break. |
192 | + """ |
193 | + # Due to the path prefix, all requested files from the client will |
194 | + # contain that prefix. Removing the prefix from the path will return |
195 | + # the correct path in the tftp root. |
196 | + if 'path' in extra: |
197 | + path = extra['path'] |
198 | + path = path.replace(self.path_prefix, '', 1) |
199 | + target_path = backend.base.descendant(path.split('/')) |
200 | + return FilesystemReader(target_path) |
201 | + |
202 | + # Return empty config for PowerNV local. PowerNV fails to |
203 | + # support the LOCALBOOT flag. Empty config will allow it |
204 | + # to select the first device. |
205 | + if kernel_params.purpose == 'local': |
206 | + return BytesReader("".encode("utf-8")) |
207 | + |
208 | + template = self.get_template( |
209 | + kernel_params.purpose, kernel_params.arch, |
210 | + kernel_params.subarch) |
211 | + namespace = self.compose_template_namespace(kernel_params) |
212 | + |
213 | + # Modify the kernel_command to inject the BOOTIF. PowerNV fails to |
214 | + # support the IPAPPEND pxelinux flag. |
215 | + def kernel_command(params): |
216 | + cmd_line = compose_kernel_command_line(params) |
217 | + if 'mac' in extra: |
218 | + mac = extra['mac'] |
219 | + mac = format_bootif(mac) |
220 | + return '%s BOOTIF=%s' % (cmd_line, mac) |
221 | + return cmd_line |
222 | + |
223 | + namespace['kernel_command'] = kernel_command |
224 | + return BytesReader(template.substitute(namespace).encode("utf-8")) |
225 | + |
226 | + def install_bootloader(self, destination): |
227 | + """Does nothing. No extra boot files are required. All of the boot |
228 | + files from PXEBootMethod will suffice.""" |
229 | |
230 | === added file 'src/provisioningserver/boot/tests/test_powernv.py' |
231 | --- src/provisioningserver/boot/tests/test_powernv.py 1970-01-01 00:00:00 +0000 |
232 | +++ src/provisioningserver/boot/tests/test_powernv.py 2014-06-02 20:19:07 +0000 |
233 | @@ -0,0 +1,337 @@ |
234 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
235 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
236 | + |
237 | +"""Tests for `provisioningserver.boot.powernv`.""" |
238 | + |
239 | +from __future__ import ( |
240 | + absolute_import, |
241 | + print_function, |
242 | + unicode_literals, |
243 | + ) |
244 | + |
245 | +str = None |
246 | + |
247 | +__metaclass__ = type |
248 | +__all__ = [] |
249 | + |
250 | +import os |
251 | +import re |
252 | + |
253 | +from maastesting.factory import factory |
254 | +from maastesting.testcase import MAASTestCase |
255 | +from provisioningserver.boot import BytesReader |
256 | +from provisioningserver.boot.powernv import ( |
257 | + ARP_HTYPE, |
258 | + format_bootif, |
259 | + PowerNVBootMethod, |
260 | + re_config_file, |
261 | + ) |
262 | +from provisioningserver.boot.tests.test_pxe import parse_pxe_config |
263 | +from provisioningserver.boot.tftppath import compose_image_path |
264 | +from provisioningserver.testing.config import set_tftp_root |
265 | +from provisioningserver.tests.test_kernel_opts import make_kernel_parameters |
266 | +from provisioningserver.tftp import TFTPBackend |
267 | +from testtools.matchers import ( |
268 | + IsInstance, |
269 | + MatchesAll, |
270 | + MatchesRegex, |
271 | + Not, |
272 | + StartsWith, |
273 | + ) |
274 | + |
275 | + |
276 | +def compose_config_path(mac): |
277 | + """Compose the TFTP path for a PowerNV PXE configuration file. |
278 | + |
279 | + The path returned is relative to the TFTP root, as it would be |
280 | + identified by clients on the network. |
281 | + |
282 | + :param mac: A MAC address, in IEEE 802 hyphen-separated form, |
283 | + corresponding to the machine for which this configuration is |
284 | + relevant. This relates to PXELINUX's lookup protocol. |
285 | + :return: Path for the corresponding PXE config file as exposed over |
286 | + TFTP. |
287 | + """ |
288 | + # Not using os.path.join: this is a TFTP path, not a native path. Yes, in |
289 | + # practice for us they're the same. We always assume that the ARP HTYPE |
290 | + # (hardware type) that PXELINUX sends is Ethernet. |
291 | + return "ppc64el/pxelinux.cfg/{htype:02x}-{mac}".format( |
292 | + htype=ARP_HTYPE.ETHERNET, mac=mac) |
293 | + |
294 | + |
295 | +def get_example_path_and_components(): |
296 | + """Return a plausible path and its components. |
297 | + |
298 | + The path is intended to match `re_config_file`, and the components are |
299 | + the expected groups from a match. |
300 | + """ |
301 | + components = {"mac": factory.getRandomMACAddress("-")} |
302 | + config_path = compose_config_path(components["mac"]) |
303 | + return config_path, components |
304 | + |
305 | + |
306 | +class TestPowerNVBootMethod(MAASTestCase): |
307 | + |
308 | + def make_tftp_root(self): |
309 | + """Set, and return, a temporary TFTP root directory.""" |
310 | + tftproot = self.make_dir() |
311 | + self.useFixture(set_tftp_root(tftproot)) |
312 | + return tftproot |
313 | + |
314 | + def test_compose_config_path_follows_maas_pxe_directory_layout(self): |
315 | + name = factory.make_name('config') |
316 | + self.assertEqual( |
317 | + 'ppc64el/pxelinux.cfg/%02x-%s' % (ARP_HTYPE.ETHERNET, name), |
318 | + compose_config_path(name)) |
319 | + |
320 | + def test_compose_config_path_does_not_include_tftp_root(self): |
321 | + tftproot = self.make_tftp_root() |
322 | + name = factory.make_name('config') |
323 | + self.assertThat( |
324 | + compose_config_path(name), |
325 | + Not(StartsWith(tftproot))) |
326 | + |
327 | + def test_bootloader_path(self): |
328 | + method = PowerNVBootMethod() |
329 | + self.assertEqual('pxelinux.0', method.bootloader_path) |
330 | + |
331 | + def test_bootloader_path_does_not_include_tftp_root(self): |
332 | + tftproot = self.make_tftp_root() |
333 | + method = PowerNVBootMethod() |
334 | + self.assertThat( |
335 | + method.bootloader_path, |
336 | + Not(StartsWith(tftproot))) |
337 | + |
338 | + def test_name(self): |
339 | + method = PowerNVBootMethod() |
340 | + self.assertEqual('powernv', method.name) |
341 | + |
342 | + def test_template_subdir(self): |
343 | + method = PowerNVBootMethod() |
344 | + self.assertEqual('pxe', method.template_subdir) |
345 | + |
346 | + def test_arch_octet(self): |
347 | + method = PowerNVBootMethod() |
348 | + self.assertEqual('00:0E', method.arch_octet) |
349 | + |
350 | + def test_path_prefix(self): |
351 | + method = PowerNVBootMethod() |
352 | + self.assertEqual('ppc64el/', method.path_prefix) |
353 | + |
354 | + |
355 | +class TestPowerNVBootMethodMatchPath(MAASTestCase): |
356 | + """Tests for |
357 | + `provisioningserver.boot.powernv.PowerNVBootMethod.match_path`. |
358 | + """ |
359 | + |
360 | + def test_match_path_pxe_config_with_mac(self): |
361 | + method = PowerNVBootMethod() |
362 | + config_path, expected = get_example_path_and_components() |
363 | + params = method.match_path(None, config_path) |
364 | + expected['arch'] = 'ppc64el' |
365 | + self.assertEqual(expected, params) |
366 | + |
367 | + def test_match_path_pxe_config_without_mac(self): |
368 | + method = PowerNVBootMethod() |
369 | + fake_mac = factory.getRandomMACAddress() |
370 | + self.patch(method, 'get_remote_mac').return_value = fake_mac |
371 | + config_path = 'ppc64el/pxelinux.cfg/default' |
372 | + params = method.match_path(None, config_path) |
373 | + expected = { |
374 | + 'arch': 'ppc64el', |
375 | + 'mac': fake_mac, |
376 | + } |
377 | + self.assertEqual(expected, params) |
378 | + |
379 | + def test_match_path_pxe_prefix_request(self): |
380 | + method = PowerNVBootMethod() |
381 | + fake_mac = factory.getRandomMACAddress() |
382 | + self.patch(method, 'get_remote_mac').return_value = fake_mac |
383 | + file_path = 'ppc64el/file' |
384 | + params = method.match_path(None, file_path) |
385 | + expected = { |
386 | + 'arch': 'ppc64el', |
387 | + 'mac': fake_mac, |
388 | + 'path': file_path, |
389 | + } |
390 | + self.assertEqual(expected, params) |
391 | + |
392 | + |
393 | +class TestPowerNVBootMethodRenderConfig(MAASTestCase): |
394 | + """Tests for |
395 | + `provisioningserver.boot.powernv.PowerNVBootMethod.get_reader` |
396 | + """ |
397 | + |
398 | + def test_get_reader_install(self): |
399 | + # Given the right configuration options, the PXE configuration is |
400 | + # correctly rendered. |
401 | + method = PowerNVBootMethod() |
402 | + params = make_kernel_parameters(self, purpose="install") |
403 | + output = method.get_reader(backend=None, kernel_params=params) |
404 | + # The output is a BytesReader. |
405 | + self.assertThat(output, IsInstance(BytesReader)) |
406 | + output = output.read(10000) |
407 | + # The template has rendered without error. PXELINUX configurations |
408 | + # typically start with a DEFAULT line. |
409 | + self.assertThat(output, StartsWith("DEFAULT ")) |
410 | + # The PXE parameters are all set according to the options. |
411 | + image_dir = compose_image_path( |
412 | + arch=params.arch, subarch=params.subarch, |
413 | + release=params.release, label=params.label) |
414 | + self.assertThat( |
415 | + output, MatchesAll( |
416 | + MatchesRegex( |
417 | + r'.*^\s+KERNEL %s/di-kernel$' % re.escape(image_dir), |
418 | + re.MULTILINE | re.DOTALL), |
419 | + MatchesRegex( |
420 | + r'.*^\s+INITRD %s/di-initrd$' % re.escape(image_dir), |
421 | + re.MULTILINE | re.DOTALL), |
422 | + MatchesRegex( |
423 | + r'.*^\s+APPEND .+?$', |
424 | + re.MULTILINE | re.DOTALL))) |
425 | + |
426 | + def test_get_reader_with_extra_arguments_does_not_affect_output(self): |
427 | + # get_reader() allows any keyword arguments as a safety valve. |
428 | + method = PowerNVBootMethod() |
429 | + options = { |
430 | + "backend": None, |
431 | + "kernel_params": make_kernel_parameters(self, purpose="install"), |
432 | + } |
433 | + # Capture the output before sprinking in some random options. |
434 | + output_before = method.get_reader(**options).read(10000) |
435 | + # Sprinkle some magic in. |
436 | + options.update( |
437 | + (factory.make_name("name"), factory.make_name("value")) |
438 | + for _ in range(10)) |
439 | + # Capture the output after sprinking in some random options. |
440 | + output_after = method.get_reader(**options).read(10000) |
441 | + # The generated template is the same. |
442 | + self.assertEqual(output_before, output_after) |
443 | + |
444 | + def test_get_reader_with_local_purpose(self): |
445 | + # If purpose is "local", output should be empty string. |
446 | + method = PowerNVBootMethod() |
447 | + options = { |
448 | + "backend": None, |
449 | + "kernel_params": make_kernel_parameters(purpose="local"), |
450 | + } |
451 | + output = method.get_reader(**options).read(10000) |
452 | + self.assertIn("", output) |
453 | + |
454 | + def test_get_reader_appends_bootif(self): |
455 | + method = PowerNVBootMethod() |
456 | + fake_mac = factory.getRandomMACAddress() |
457 | + params = make_kernel_parameters(self, purpose="install") |
458 | + output = method.get_reader( |
459 | + backend=None, kernel_params=params, arch='ppc64el', mac=fake_mac) |
460 | + output = output.read(10000) |
461 | + config = parse_pxe_config(output) |
462 | + expected = 'BOOTIF=%s' % format_bootif(fake_mac) |
463 | + self.assertIn(expected, config['execute']['APPEND']) |
464 | + |
465 | + |
466 | +class TestPowerNVBootMethodPathPrefix(MAASTestCase): |
467 | + """Tests for |
468 | + `provisioningserver.boot.powernv.PowerNVBootMethod.get_reader`. |
469 | + """ |
470 | + |
471 | + def test_get_reader_path_prefix(self): |
472 | + data = factory.getRandomString().encode("ascii") |
473 | + temp_file = self.make_file(name="example", contents=data) |
474 | + temp_dir = os.path.dirname(temp_file) |
475 | + backend = TFTPBackend(temp_dir, "http://nowhere.example.com/") |
476 | + method = PowerNVBootMethod() |
477 | + options = { |
478 | + 'backend': backend, |
479 | + 'kernel_params': make_kernel_parameters(), |
480 | + 'path': 'ppc64el/example', |
481 | + } |
482 | + reader = method.get_reader(**options) |
483 | + self.addCleanup(reader.finish) |
484 | + self.assertEqual(len(data), reader.size) |
485 | + self.assertEqual(data, reader.read(len(data))) |
486 | + self.assertEqual(b"", reader.read(1)) |
487 | + |
488 | + def test_get_reader_path_prefix_only_removes_first_occurrence(self): |
489 | + data = factory.getRandomString().encode("ascii") |
490 | + temp_dir = self.make_dir() |
491 | + temp_subdir = os.path.join(temp_dir, 'ppc64el') |
492 | + os.mkdir(temp_subdir) |
493 | + factory.make_file(temp_subdir, "example", data) |
494 | + backend = TFTPBackend(temp_dir, "http://nowhere.example.com/") |
495 | + method = PowerNVBootMethod() |
496 | + options = { |
497 | + 'backend': backend, |
498 | + 'kernel_params': make_kernel_parameters(), |
499 | + 'path': 'ppc64el/ppc64el/example', |
500 | + } |
501 | + reader = method.get_reader(**options) |
502 | + self.addCleanup(reader.finish) |
503 | + self.assertEqual(len(data), reader.size) |
504 | + self.assertEqual(data, reader.read(len(data))) |
505 | + self.assertEqual(b"", reader.read(1)) |
506 | + |
507 | + |
508 | +class TestPowerNVBootMethodRegex(MAASTestCase): |
509 | + """Tests for |
510 | + `provisioningserver.boot.powernv.PowerNVBootMethod.re_config_file`. |
511 | + """ |
512 | + |
513 | + def test_re_config_file_is_compatible_with_config_path_generator(self): |
514 | + # The regular expression for extracting components of the file path is |
515 | + # compatible with the PXE config path generator. |
516 | + for iteration in range(10): |
517 | + config_path, args = get_example_path_and_components() |
518 | + match = re_config_file.match(config_path) |
519 | + self.assertIsNotNone(match, config_path) |
520 | + self.assertEqual(args, match.groupdict()) |
521 | + |
522 | + def test_re_config_file_with_leading_slash(self): |
523 | + # The regular expression for extracting components of the file path |
524 | + # doesn't care if there's a leading forward slash; the TFTP server is |
525 | + # easy on this point, so it makes sense to be also. |
526 | + config_path, args = get_example_path_and_components() |
527 | + # Ensure there's a leading slash. |
528 | + config_path = "/" + config_path.lstrip("/") |
529 | + match = re_config_file.match(config_path) |
530 | + self.assertIsNotNone(match, config_path) |
531 | + self.assertEqual(args, match.groupdict()) |
532 | + |
533 | + def test_re_config_file_without_leading_slash(self): |
534 | + # The regular expression for extracting components of the file path |
535 | + # doesn't care if there's no leading forward slash; the TFTP server is |
536 | + # easy on this point, so it makes sense to be also. |
537 | + config_path, args = get_example_path_and_components() |
538 | + # Ensure there's no leading slash. |
539 | + config_path = config_path.lstrip("/") |
540 | + match = re_config_file.match(config_path) |
541 | + self.assertIsNotNone(match, config_path) |
542 | + self.assertEqual(args, match.groupdict()) |
543 | + |
544 | + def test_re_config_file_matches_classic_pxelinux_cfg(self): |
545 | + # The default config path is simply "pxelinux.cfg" (without |
546 | + # leading slash). The regex matches this. |
547 | + mac = 'aa-bb-cc-dd-ee-ff' |
548 | + match = re_config_file.match('ppc64el/pxelinux.cfg/01-%s' % mac) |
549 | + self.assertIsNotNone(match) |
550 | + self.assertEqual({'mac': mac}, match.groupdict()) |
551 | + |
552 | + def test_re_config_file_matches_pxelinux_cfg_with_leading_slash(self): |
553 | + mac = 'aa-bb-cc-dd-ee-ff' |
554 | + match = re_config_file.match('/ppc64el/pxelinux.cfg/01-%s' % mac) |
555 | + self.assertIsNotNone(match) |
556 | + self.assertEqual({'mac': mac}, match.groupdict()) |
557 | + |
558 | + def test_re_config_file_does_not_match_non_config_file(self): |
559 | + self.assertIsNone(re_config_file.match('ppc64el/pxelinux.cfg/kernel')) |
560 | + |
561 | + def test_re_config_file_does_not_match_file_in_root(self): |
562 | + self.assertIsNone(re_config_file.match('01-aa-bb-cc-dd-ee-ff')) |
563 | + |
564 | + def test_re_config_file_does_not_match_file_not_in_pxelinux_cfg(self): |
565 | + self.assertIsNone(re_config_file.match('foo/01-aa-bb-cc-dd-ee-ff')) |
566 | + |
567 | + def test_re_config_file_with_default(self): |
568 | + match = re_config_file.match('ppc64el/pxelinux.cfg/default') |
569 | + self.assertIsNotNone(match) |
570 | + self.assertEqual({'mac': None}, match.groupdict()) |
571 | |
572 | === modified file 'src/provisioningserver/dhcp/config.py' |
573 | --- src/provisioningserver/dhcp/config.py 2014-04-01 12:57:55 +0000 |
574 | +++ src/provisioningserver/dhcp/config.py 2014-06-02 20:19:07 +0000 |
575 | @@ -33,16 +33,22 @@ |
576 | |
577 | # Used to generate the conditional bootloader behaviour |
578 | CONDITIONAL_BOOTLOADER = """ |
579 | -{behaviour} option arch = {arch_octet} {{ |
580 | - filename \"{bootloader}\"; |
581 | - }} |
582 | +{{behaviour}} option arch = {{arch_octet}} { |
583 | + filename \"{{bootloader}}\"; |
584 | + {{if path_prefix}} |
585 | + option path-prefix \"{{path_prefix}}\"; |
586 | + {{endif}} |
587 | + } |
588 | """ |
589 | |
590 | # Used to generate the PXEBootLoader special case |
591 | PXE_BOOTLOADER = """ |
592 | -else {{ |
593 | - filename \"{bootloader}\"; |
594 | - }} |
595 | +else { |
596 | + filename \"{{bootloader}}\"; |
597 | + {{if path_prefix}} |
598 | + option path-prefix \"{{path_prefix}}\"; |
599 | + {{endif}} |
600 | + } |
601 | """ |
602 | |
603 | |
604 | @@ -55,9 +61,13 @@ |
605 | behaviour = chain(["if"], repeat("elsif")) |
606 | for name, method in BootMethodRegistry: |
607 | if name != "pxe": |
608 | - output += CONDITIONAL_BOOTLOADER.format( |
609 | - behaviour=next(behaviour), arch_octet=method.arch_octet, |
610 | - bootloader=method.bootloader_path).strip() + ' ' |
611 | + output += tempita.sub( |
612 | + CONDITIONAL_BOOTLOADER, |
613 | + behaviour=next(behaviour), |
614 | + arch_octet=method.arch_octet, |
615 | + bootloader=method.bootloader_path, |
616 | + path_prefix=method.path_prefix, |
617 | + ).strip() + ' ' |
618 | |
619 | # The PXEBootMethod is used in an else statement for the generated |
620 | # dhcpd config. This ensures that a booting node that does not |
621 | @@ -65,8 +75,11 @@ |
622 | # pxelinux can still boot. |
623 | pxe_method = BootMethodRegistry.get_item('pxe') |
624 | if pxe_method is not None: |
625 | - output += PXE_BOOTLOADER.format( |
626 | - bootloader=pxe_method.bootloader_path).strip() |
627 | + output += tempita.sub( |
628 | + PXE_BOOTLOADER, |
629 | + bootloader=pxe_method.bootloader_path, |
630 | + path_prefix=pxe_method.path_prefix, |
631 | + ).strip() |
632 | return output.strip() |
633 | |
634 | |
635 | |
636 | === modified file 'src/provisioningserver/driver/__init__.py' |
637 | --- src/provisioningserver/driver/__init__.py 2014-05-15 17:39:27 +0000 |
638 | +++ src/provisioningserver/driver/__init__.py 2014-06-02 20:19:07 +0000 |
639 | @@ -139,7 +139,14 @@ |
640 | Architecture( |
641 | name="armhf/generic", description="armhf/generic", |
642 | pxealiases=["arm"], kernel_options=["console=ttyAMA0"]), |
643 | - Architecture(name="ppc64el/generic", description="ppc64el"), |
644 | + # PPC64EL needs a rootdelay for PowerNV. The disk controller |
645 | + # in the hardware, takes a little bit longer to come up then |
646 | + # the initrd wants to wait. Set this to 60 seconds, just to |
647 | + # give the booting machine enough time. This doesn't slow down |
648 | + # the booting process, it just increases the timeout. |
649 | + Architecture( |
650 | + name="ppc64el/generic", description="ppc64el", |
651 | + kernel_options=['rootdelay=60']), |
652 | ] |
653 | for arch in builtin_architectures: |
654 | ArchitectureRegistry.register_item(arch.name, arch) |
655 | |
656 | === modified file 'src/provisioningserver/utils/__init__.py' |
657 | --- src/provisioningserver/utils/__init__.py 2014-05-06 21:18:17 +0000 |
658 | +++ src/provisioningserver/utils/__init__.py 2014-06-02 20:19:07 +0000 |
659 | @@ -829,3 +829,24 @@ |
660 | if len(columns) == 5 and columns[2] == mac: |
661 | return columns[0] |
662 | return None |
663 | + |
664 | + |
665 | +def find_mac_via_arp(ip): |
666 | + """Find the MAC address for `ip` by reading the output of arp -n. |
667 | + |
668 | + Returns `None` if the IP is not found. |
669 | + |
670 | + We do this because we aren't necessarily the only DHCP server on the |
671 | + network, so we can't check our own leases file and be guaranteed to find an |
672 | + IP that matches. |
673 | + |
674 | + :param ip: The ip address, e.g. '192.168.1.1'. |
675 | + """ |
676 | + |
677 | + output = call_capture_and_check(['arp', '-n']).split('\n') |
678 | + |
679 | + for line in sorted(output): |
680 | + columns = line.split() |
681 | + if len(columns) == 5 and columns[0] == ip: |
682 | + return columns[2] |
683 | + return None |
684 | |
685 | === modified file 'src/provisioningserver/utils/tests/test_utils.py' |
686 | --- src/provisioningserver/utils/tests/test_utils.py 2014-05-06 21:18:17 +0000 |
687 | +++ src/provisioningserver/utils/tests/test_utils.py 2014-06-02 20:19:07 +0000 |
688 | @@ -75,6 +75,7 @@ |
689 | ExternalProcessError, |
690 | filter_dict, |
691 | find_ip_via_arp, |
692 | + find_mac_via_arp, |
693 | get_all_interface_addresses, |
694 | get_mtime, |
695 | incremental_write, |
696 | @@ -1322,6 +1323,50 @@ |
697 | self.assertEqual("192.168.0.1", ip_address_observed) |
698 | |
699 | |
700 | +class TestFindMACViaARP(MAASTestCase): |
701 | + |
702 | + def patch_call(self, output): |
703 | + """Replace `call_capture_and_check` with one that returns `output`.""" |
704 | + fake = self.patch(provisioningserver.utils, 'call_capture_and_check') |
705 | + fake.return_value = output |
706 | + return fake |
707 | + |
708 | + def test__resolves_IP_address_to_MAC(self): |
709 | + sample = """\ |
710 | + Address HWtype HWaddress Flags Mask Iface |
711 | + 192.168.100.20 (incomplete) virbr1 |
712 | + 192.168.0.104 (incomplete) eth0 |
713 | + 192.168.0.5 (incomplete) eth0 |
714 | + 192.168.0.2 (incomplete) eth0 |
715 | + 192.168.0.100 (incomplete) eth0 |
716 | + 192.168.122.20 ether 52:54:00:02:86:4b C virbr0 |
717 | + 192.168.0.4 (incomplete) eth0 |
718 | + 192.168.0.1 ether 90:f6:52:f6:17:92 C eth0 |
719 | + """ |
720 | + |
721 | + call_capture_and_check = self.patch_call(sample) |
722 | + mac_address_observed = find_mac_via_arp("192.168.122.20") |
723 | + self.assertThat( |
724 | + call_capture_and_check, |
725 | + MockCalledOnceWith(['arp', '-n'])) |
726 | + self.assertEqual("52:54:00:02:86:4b", mac_address_observed) |
727 | + |
728 | + def test__returns_consistent_output(self): |
729 | + ip = factory.getRandomIPAddress() |
730 | + macs = [ |
731 | + '52:54:00:02:86:4b', |
732 | + '90:f6:52:f6:17:92', |
733 | + ] |
734 | + lines = ['%s ether %s C eth0' % (ip, mac) for mac in macs] |
735 | + self.patch_call('\n'.join(lines)) |
736 | + one_result = find_mac_via_arp(ip) |
737 | + self.patch_call('\n'.join(reversed(lines))) |
738 | + other_result = find_mac_via_arp(ip) |
739 | + |
740 | + self.assertIn(one_result, macs) |
741 | + self.assertEqual(one_result, other_result) |
742 | + |
743 | + |
744 | class TestAsynchronousDecorator(MAASTestCase): |
745 | |
746 | run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5) |