Merge lp:~blake-rouse/maas/powernv-support into lp:~maas-committers/maas/trunk

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: no longer in the source branch.
Merged at revision: 2397
Proposed branch: lp:~blake-rouse/maas/powernv-support
Merge into: lp:~maas-committers/maas/trunk
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
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Jason Hobbs (community) Approve
Review via email: mp+220840@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

Add support for PowerNV. PowerNV boots using pxelinux emulation. The dhcpd option 210 is used to prefix all PXE requests with "ppc64el/". This allows the PowerNVBootMethod to identify that the booting node is architecture ppc64el.

compose_conditional_bootloader was modified to support the new path-prefix dhcpd config option. A boot method sets path_prefix attribute, and when the dhcpd config is generated that option will be placed in the generated dhcpd config for that architecture.

PowerNV has some issues that the boot method handles.

1. IPAPPEND 2 is not support.
  - The BOOTIF= kernel parameter has to be manual injected into the kernel parameters as PowerNV does not support the IPAPPEND option in the pxelinux.cfg. This is done by using the provided mac address from the pxelinux.cfg request, or by getting the mac address from the arp cache using the remote_host ip address.

2. LOCALBOOT is not supported.
  - Without localboot support MAAS needs to be able to tell the node to boot from local disk. The only work around for this, is to return an empty pxelinux.cfg config when booting local. This will cause the PowerNV node to ignore the PXE boot, and boot from the first disk.

To post a comment you must log in.
Revision history for this message
Jason Hobbs (jason-hobbs) wrote :
Download full text (4.3 KiB)

Blake, not done reviewing yet but here's my comments so far:

[1]

=== added file 'etc/maas/templates/pxe/config.commissioning.ppc64el.template'
--- etc/maas/templates/pxe/config.commissioning.ppc64el.template 1970-01-01 00:00:00 +0000
+++ etc/maas/templates/pxe/config.commissioning.ppc64el.template 2014-05-28 21:38:08 +0000
@@ -0,0 +1,6 @@
+DEFAULT execute
+
+LABEL execute
+ KERNEL {{kernel_params | kernel_path }}
+ INITRD {{kernel_params | initrd_path }}
+ APPEND {{kernel_params | kernel_command}}

=== added file 'etc/maas/templates/pxe/config.install.ppc64el.template'
...
=== added file 'etc/maas/templates/pxe/config.xinstall.ppc64el.template'

I think the duplicate files here should be symlinks - armhf does this
right now.

[2]

+# PXELINUX represents a MAC address in IEEE 802 hyphen-separated
+# format. See http://www.syslinux.org/wiki/index.php/PXELINUX.
+re_mac_address_octet = r'[0-9a-f]{2}'
+re_mac_address = re.compile(
+ "-".join(repeat(re_mac_address_octet, 6)))

This is copied directly from pxe.py. It should either be imported
from there or some other common location.

+# The pxelinux.cfg path is prefixed with the architecture for the
+# PowerNV nodes. This prefix is set by the path-prefix dhcpd option.
+# We assume that the ARP HTYPE (hardware type) that PXELINUX sends is
+# always Ethernet.
+re_config_file = r'''
+ # Optional leading slash(es).
+ ^/*
+ ppc64el # PowerNV pxe prefix, set by dhcpd
+ /
+ pxelinux[.]cfg # PXELINUX expects this.
+ /
+ (?: # either a MAC
+ {htype:02x} # ARP HTYPE.
+ -
+ (?P<mac>{re_mac_address.pattern}) # Capture MAC.
+ | # or "default"
+ default
+ )
+ $
+'''

[3]

This is very similar to the re_config_file in pxe.py. Perhaps a common
template could be used to generate them?

+re_config_file = re_config_file.format(
+ htype=ARP_HTYPE.ETHERNET, re_mac_address=re_mac_address)
+re_config_file = re.compile(re_config_file, re.VERBOSE)

[4]

Copied from boot.py - should be common.

[5]

+ def get_reader(self, backend, kernel_params, **extra):
+ """Render a configuration file as a unicode string.
+
+ :param backend: requesting backend
+ :param kernel_params: An instance of `KernelParameters`.
+ :param extra: Allow for other arguments. This is a safety valve;
+ parameters generated in another component (for example, see
+ `TFTPBackend.get_config_reader`) won't cause this to break.
+ """
+ # Due to the path prefix, all requested files from the client will
+ # contain that prefix. Lets return the correct file, removing the
+ # prefix from the path
+ if 'path' in extra:
+ path = extra['path']
+ path = path.replace(self.path_prefix, '', 1)
+ target_path = backend.base.descendant(path.split('/'))
+ return FilesystemReader(target_path)
+
+ # Return empty config for PowerNV local. PowerNV fails to
+ # support the LOCALBOOT flag. Empty config will allow it
+ # to select the first device.
+ if kernel_params.purpose == 'local':
+ return BytesReader("".encode("utf-8"))...

Read more...

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

[3] The regex is actually different in that it adds the ppc64el path, and it removes the arch and sub-arch parameters from the regex. So it needs to be seperate from PXEBootMethod.

[4] Also you cannot import those as when you import re_config_file, it is already the compiled regex, which would be the incorrect regex for ppc64el.

[5] Yes the method returns a ByteReader at the end of the method.

[7] MAAS will not boot any normally PXE machine without the PXEBootMethod. So it can easily be assumed that those files will be installed by the PXEBootMethod. No reason to copy the code from the PXEBootMethod, that will already do the same time.

Revision history for this message
Jason Hobbs (jason-hobbs) wrote :

Here's the rest of my review. Overall it looks really good! I approve as long as the duplication in 1 & 2 get cleaned up. I think some more near duplication could be cleaned up with some creativity but I wouldn't block on it.

[9]

- Architecture(name="ppc64el/generic", description="ppc64el"),
+ Architecture(
+ name="ppc64el/generic", description="ppc64el",
+ kernel_options=['rootdelay=60']),

A comment on why the rootdelay is needed and why it's 60 would be nice.

[10]

=== modified file 'src/provisioningserver/import_images/download_resources.py'
--- src/provisioningserver/import_images/download_resources.py 2014-05-23 09:48:49 +0000
+++ src/provisioningserver/import_images/download_resources.py 2014-05-28 21:38:08 +0000
@@ -19,6 +19,8 @@
 from datetime import datetime
 from gzip import GzipFile
 import os.path
+import tempfile
+from urlparse import urlsplit

 from provisioningserver.import_images.helpers import (
     get_signing_policy,

Why are these new imports added when there are no other changes to this file?

=== modified file 'src/provisioningserver/utils/__init__.py'
--- src/provisioningserver/utils/__init__.py 2014-05-05 23:55:46 +0000
+++ src/provisioningserver/utils/__init__.py 2014-05-28 21:38:08 +0000
@@ -829,3 +829,24 @@
         if len(columns) == 5 and columns[2] == mac:
             return columns[0]
     return None

[11]

+def find_mac_via_ip(ip):
+ """Find the MAC address for `ip` by reading the output of arp -n.
+
+ Returns `None` if the IP is not found.
+
+ We do this because we aren't necessarily the only DHCP server on the
+ network, so we can't check our own leases file and be guaranteed to find an
+ IP that matches.
+
+ :param ip: The ip address, e.g. '192.168.1.1'.
+ """
+
+ output = call_capture_and_check(['arp', '-n']).split('\n')
+
+ for line in sorted(output):
+ columns = line.split()
+ if len(columns) == 5 and columns[0] == ip:
+ return columns[2]
+ return None

I don't see a unit test for this method.

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

Fixed the requested items. Added tests for find_mac_via_arp.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Great reviewing. I have some small things to add as well:

What does it mean for match_path to return None? Is that tested? If that's an "unthinkable" situation, it'd be better to raise an explicit error.

.

A comment in get_reader says: "Lets return the correct file." That's a bit vague — if you're going to return a file, yes, let's make the correct one! I find that "let's" and "the correct" are often telltales of things being left unclear.

.

In test_re_config_file_is_compatible_with_config_path_generator, is there any particular reason to repeat basically the same test in a loop? If you have a potential bug in mind that would only trigger very rarely, it'd be better to have a separate test for that case.

.

In find_mac_via_arp you use call_capture_and_check, and TestFindMACViaARP.patch_call patches it. That function is now call_and_check. (Or rather, there were two functions which have merged under that name.)

.

All in all the comments and docstrings are very helpful, although I still get lost in places!

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

The return None is required, that tells the TFTP backend that this boot method should not be used for the requested file.

Fixing the other comments. Thanks!

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

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (46.6 KiB)

The attempt to merge lp:~blake-rouse/maas/powernv-support into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [58.5 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [58.5 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [16.6 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [4,212 B]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [53.2 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [17.9 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Ign http://security.ubuntu.com trusty-security/main Translation-en_US
Ign http://security.ubuntu.com trusty-security/universe Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [47.6 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [28.6 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [117 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [77.2 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en_US
Fetched 481 kB in 0s (2,077 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make postgresql python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-formencode python-httplib2 python-jinja2 python...

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 03:20:42 +0000
3+++ etc/maas/templates/dhcp/dhcpd.conf.template 2014-06-02 19:13:29 +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 19:13:29 +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 19:13:29 +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-27 19:43:17 +0000
39+++ src/provisioningserver/boot/__init__.py 2014-06-02 19:13:29 +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 19:13:29 +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 19:13:29 +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+ osystem=params.osystem, 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 19:13:29 +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-29 18:20:58 +0000
638+++ src/provisioningserver/driver/__init__.py 2014-06-02 19:13:29 +0000
639@@ -216,7 +216,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-29 17:42:18 +0000
658+++ src/provisioningserver/utils/__init__.py 2014-06-02 19:13:29 +0000
659@@ -823,3 +823,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-29 17:42:18 +0000
687+++ src/provisioningserver/utils/tests/test_utils.py 2014-06-02 19:13:29 +0000
688@@ -74,6 +74,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@@ -1347,6 +1348,50 @@
697 self.assertEqual(one_result, other_result)
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)