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