Merge lp:~racb/maas/arch-detect into lp:~maas-committers/maas/trunk

Proposed by Robie Basak
Status: Merged
Approved by: Robie Basak
Approved revision: no longer in the source branch.
Merged at revision: 1142
Proposed branch: lp:~racb/maas/arch-detect
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 371 lines (+175/-45)
4 files modified
src/maasserver/api.py (+66/-12)
src/maasserver/tests/test_api.py (+36/-25)
src/provisioningserver/tests/test_tftp.py (+30/-3)
src/provisioningserver/tftp.py (+43/-5)
To merge this branch: bzr merge lp:~racb/maas/arch-detect
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+127458@code.launchpad.net

Commit message

Add architecture detection

Rather than always responding to pxelinux.cfg/01-<mac> dynamically, instead cause nodes to fall back to pxelinux.cfg/default if the architecture of the machine with the provided MAC address is unknown. This allows non-Intel machines to fall back to pxelinux.cfg/default.<arch>-<subarch> in the middle, at which point serve a configuration for the correct non-Intel architecture.

This allows multiple architectures to be served the correct pxelinux.cfg configuration for enlistment without any needed intervention.

See bug 1041092 for details on pxelinux.cfg/default.<arch>-<subarch>.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Several minor comments, but they're mostly about style; the approach
is good, afaict.

[1]

+    if macaddress:
+        return macaddress.node
+    else:
+        return None

Don't know how you feel about conditional expressions, but this might
be as clear but more concise as:

    return macaddress.node if macaddress else None

[2]

+    :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
+                 'armhf').

Typically we align follow-on lines 4-spaces in from the first
line. Yay, another trivial nitpick :)

[3]

+                # Request was pxelinux.cfg/01-<mac>, so attempt fall back
+                # to pxelinux.cfg/default-<arch> for arch detection.

Is it default-<arch> or default.<arch>?

Update: the regex says either.

[4]

+            if pxelinux_arch == 'arm':
+                arch = 'armhf'
+            else:
+                arch = pxelinux_arch

One step in the direction of a lookup-table, and more concise:

            arch = {"arm": "armhf"}.get(pxelinux_arch, pxelinux_arch)

Er, but it's not very pretty. Never mind. Why is "arm" being sent
instead of "armhf"? Should we normalise on just "arm"?

[5]

+        response = self.client.get(reverse('pxeconfig'),
+                                   self.get_default_params())

We have followed the style of wrapping 4 spaces in most places:

        response = self.client.get(
            reverse('pxeconfig'), self.get_default_params())

Also, when we need to break onto a newline, we break at the opening
brace, so not like this:

        response = self.client.get(reverse('pxeconfig'),
            self.get_default_params())

Too much code on Launchpad ended up in the rightmost 20 columns
(there, like here, we chose to use a right margin) so we changed the
convention.

Hardly needs saying, but Emacs' python-mode does this all perfectly,
right out of the box ;)

[6]

+        ( # either a MAC

Make this a non-capturing group, i.e. (?:...)

+            {htype:02x}    # ARP HTYPE.
+            -
+            (?P<mac>{re_mac_address.pattern})    # Capture MAC.
+        | # or "default"
+            default
+              ( # perhaps with specified arch

Here too?

+                [.-](?P<arch>\w+) # arch
+                (-(?P<subarch>\w+))? # optional subarch

And here?

+              )?

[7]

+            params = {k: v
+                      for k, v in config_file_match.groupdict().iteritems()
+                      if v is not None}

We have tended to avoid single-letter variable names (sorry, again,
this is Launchpad engineering heritage showing its face, along with my
pedantry). Also, we decided to use the non-iter methods on dicts, I
think because they're spelled the same as in Python 3. I preferred to
use them, because they behave differently, and 2to3 translates them
anyway, but we, Red Squad, in days of old, agreed this. So, we'd write
this as:

            params = {
                key: value
                for key, value in config_file_match.groupdict().items()
                if value is not None
                }

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

>                                                    ... I preferred to
> use them, because they behave differently, and 2to3 translates them
> anyway, but we, Red Squad, in days of old, agreed this. ...

There's a "not" missing from the start of the second line there, but
you probably guessed that.

Revision history for this message
Robie Basak (racb) wrote :

[1] Done
[2] Done
[3] Clarified separator in corresponding comments

[4]

> Why is "arm" being sent instead of "armhf"? Should we normalise on just "arm"?

Outside userspace, the "hf" part of armhf has no meaning as there is no defined userspace ABI at that point. So before userspace, armel and armhf are identical. But MAAS does care about userspace. albeit only armhf userspace for ARM, because it needs to know which userspace to deploy. So pxelinux emulators should be agnostic of el/hf yet MAAS must be aware of it. So we must map it.

[5] Done
[6] Done
[7] Done

Thanks Gavin!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2012-10-03 02:28:04 +0000
3+++ src/maasserver/api.py 2012-10-03 08:57:21 +0000
4@@ -1496,33 +1496,87 @@
5 return "poweroff"
6
7
8+def get_node_from_mac_string(mac_string):
9+ """Get a Node object from a MAC address string.
10+
11+ Returns a Node object or None if no node with the given MAC address exists.
12+
13+ :param mac_string: MAC address string in the form "12-34-56-78-9a-bc"
14+ :return: Node object or None
15+ """
16+ if mac_string is None:
17+ return None
18+ macaddress = get_one(MACAddress.objects.filter(mac_address=mac_string))
19+ return macaddress.node if macaddress else None
20+
21+
22 def pxeconfig(request):
23 """Get the PXE configuration given a node's details.
24
25 Returns a JSON object corresponding to a
26 :class:`provisioningserver.kernel_opts.KernelParameters` instance.
27
28+ This is now fairly decoupled from pxelinux's TFTP filename encoding
29+ mechanism, with one notable exception. Call this function with (mac, arch,
30+ subarch) and it will do the right thing. If details it needs are missing
31+ (ie. arch/subarch missing when the MAC is supplied but unknown), then it
32+ will as an exception return an HTTP NO_CONTENT (204) in the expectation
33+ that this will be translated to a TFTP file not found and pxelinux (or an
34+ emulator) will fall back to default-<arch>-<subarch> (in the case of an
35+ alternate architecture emulator) or just straight to default (in the case
36+ of native pxelinux on i386 or amd64). See bug 1041092 for details and
37+ discussion.
38+
39 :param mac: MAC address to produce a boot configuration for.
40+ :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
41+ 'armhf').
42+ :param subarch: Subarchitecture name (in the pxelinux namespace).
43 """
44- mac = get_mandatory_param(request.GET, 'mac')
45+ node = get_node_from_mac_string(request.GET.get('mac', None))
46
47- macaddress = get_one(MACAddress.objects.filter(mac_address=mac))
48- if macaddress is None:
49- # Default to i386 as a works-for-all solution. This will not support
50- # non-x86 architectures, but for now this assumption holds.
51- node = None
52- arch, subarch = ARCHITECTURE.i386.split('/')
53- preseed_url = compose_enlistment_preseed_url()
54- hostname = 'maas-enlist'
55- domain = Config.objects.get_config('enlistment_domain')
56- else:
57- node = macaddress.node
58+ if node:
59 arch, subarch = node.architecture.split('/')
60 preseed_url = compose_preseed_url(node)
61 # The node's hostname may include a domain, but we ignore that
62 # and use the one from the nodegroup instead.
63 hostname = node.hostname.split('.', 1)[0]
64 domain = node.nodegroup.name
65+ else:
66+ try:
67+ pxelinux_arch = request.GET['arch']
68+ except KeyError:
69+ if 'mac' in request.GET:
70+ # Request was pxelinux.cfg/01-<mac>, so attempt fall back
71+ # to pxelinux.cfg/default-<arch>-<subarch> for arch detection.
72+ return HttpResponse(status=httplib.NO_CONTENT)
73+ else:
74+ # Request has already fallen back, so if arch is still not
75+ # provided then use i386.
76+ arch = ARCHITECTURE.i386.split('/')[0]
77+ else:
78+ # Map from pxelinux namespace architecture names to MAAS namespace
79+ # architecture names. If this gets bigger, an external lookup table
80+ # would make sense. But here is fine for something as trivial as it
81+ # is right now.
82+ if pxelinux_arch == 'arm':
83+ arch = 'armhf'
84+ else:
85+ arch = pxelinux_arch
86+
87+ # Use subarch if supplied; otherwise assume 'generic'.
88+ try:
89+ pxelinux_subarch = request.GET['subarch']
90+ except KeyError:
91+ subarch = 'generic'
92+ else:
93+ # Map from pxelinux namespace subarchitecture names to MAAS
94+ # namespace subarchitecture names. Right now this happens to be a
95+ # 1-1 mapping.
96+ subarch = pxelinux_subarch
97+
98+ preseed_url = compose_enlistment_preseed_url()
99+ hostname = 'maas-enlist'
100+ domain = Config.objects.get_config('enlistment_domain')
101
102 if node is None or node.status == NODE_STATUS.COMMISSIONING:
103 series = Config.objects.get_config('commissioning_distro_series')
104
105=== modified file 'src/maasserver/tests/test_api.py'
106--- src/maasserver/tests/test_api.py 2012-10-03 02:28:04 +0000
107+++ src/maasserver/tests/test_api.py 2012-10-03 08:57:21 +0000
108@@ -2715,21 +2715,22 @@
109
110 class TestPXEConfigAPI(AnonAPITestCase):
111
112- def get_params(self):
113+ def get_mac_params(self):
114 return {'mac': factory.make_mac_address().mac_address}
115
116- def get_optional_params(self):
117- return ['mac']
118+ def get_default_params(self):
119+ return dict()
120
121 def get_pxeconfig(self, params=None):
122 """Make a request to `pxeconfig`, and return its response dict."""
123 if params is None:
124- params = self.get_params()
125+ params = self.get_default_params()
126 response = self.client.get(reverse('pxeconfig'), params)
127 return json.loads(response.content)
128
129 def test_pxeconfig_returns_json(self):
130- response = self.client.get(reverse('pxeconfig'), self.get_params())
131+ response = self.client.get(
132+ reverse('pxeconfig'), self.get_default_params())
133 self.assertThat(
134 (
135 response.status_code,
136@@ -2751,7 +2752,28 @@
137 self.get_pxeconfig(),
138 ContainsAll(KernelParameters._fields))
139
140- def test_pxeconfig_defaults_to_i386_when_node_unknown(self):
141+ def test_pxeconfig_returns_data_for_known_node(self):
142+ params = self.get_mac_params()
143+ node = MACAddress.objects.get(mac_address=params['mac']).node
144+ response = self.client.get(reverse('pxeconfig'), params)
145+ self.assertEqual(httplib.OK, response.status_code)
146+
147+ def test_pxeconfig_returns_no_content_for_unknown_node(self):
148+ params = dict(mac=factory.getRandomMACAddress(delimiter=b'-'))
149+ response = self.client.get(reverse('pxeconfig'), params)
150+ self.assertEqual(httplib.NO_CONTENT, response.status_code)
151+
152+ def test_pxeconfig_returns_data_for_detailed_but_unknown_node(self):
153+ architecture = factory.getRandomEnum(ARCHITECTURE)
154+ arch, subarch = architecture.split('/')
155+ params = dict(
156+ mac=factory.getRandomMACAddress(delimiter=b'-'),
157+ arch=arch,
158+ subarch=subarch)
159+ response = self.client.get(reverse('pxeconfig'), params)
160+ self.assertEqual(httplib.OK, response.status_code)
161+
162+ def test_pxeconfig_defaults_to_i386_for_default(self):
163 # As a lowest-common-denominator, i386 is chosen when the node is not
164 # yet known to MAAS.
165 expected_arch = tuple(ARCHITECTURE.i386.split('/'))
166@@ -2760,16 +2782,12 @@
167 self.assertEqual(expected_arch, observed_arch)
168
169 def test_pxeconfig_uses_fixed_hostname_for_enlisting_node(self):
170- new_mac = factory.getRandomMACAddress()
171- self.assertEqual(
172- 'maas-enlist',
173- self.get_pxeconfig({'mac': new_mac}).get('hostname'))
174+ self.assertEqual('maas-enlist', self.get_pxeconfig().get('hostname'))
175
176 def test_pxeconfig_uses_enlistment_domain_for_enlisting_node(self):
177- new_mac = factory.getRandomMACAddress()
178 self.assertEqual(
179 Config.objects.get_config('enlistment_domain'),
180- self.get_pxeconfig({'mac': new_mac}).get('domain'))
181+ self.get_pxeconfig().get('domain'))
182
183 def test_pxeconfig_splits_domain_from_node_hostname(self):
184 host = factory.make_name('host')
185@@ -2800,24 +2818,16 @@
186 kernel_opts, 'get_ephemeral_name',
187 FakeMethod(result=factory.getRandomString()))
188
189- def test_pxeconfig_requires_mac_address(self):
190- # The `mac` parameter is mandatory.
191- self.silence_get_ephemeral_name()
192- self.assertEqual(
193- httplib.BAD_REQUEST,
194- self.get_without_param("mac").status_code)
195-
196- def test_pxeconfig_has_enlistment_preseed_url_for_unknown_node(self):
197- self.silence_get_ephemeral_name()
198- params = self.get_params()
199- params['mac'] = factory.getRandomMACAddress()
200+ def test_pxeconfig_has_enlistment_preseed_url_for_default(self):
201+ self.silence_get_ephemeral_name()
202+ params = self.get_default_params()
203 response = self.client.get(reverse('pxeconfig'), params)
204 self.assertEqual(
205 compose_enlistment_preseed_url(),
206 json.loads(response.content)["preseed_url"])
207
208 def test_pxeconfig_has_preseed_url_for_known_node(self):
209- params = self.get_params()
210+ params = self.get_mac_params()
211 node = MACAddress.objects.get(mac_address=params['mac']).node
212 response = self.client.get(reverse('pxeconfig'), params)
213 self.assertEqual(
214@@ -2852,7 +2862,8 @@
215 def test_pxeconfig_uses_boot_purpose(self):
216 fake_boot_purpose = factory.make_name("purpose")
217 self.patch(api, "get_boot_purpose", lambda node: fake_boot_purpose)
218- response = self.client.get(reverse('pxeconfig'), self.get_params())
219+ response = self.client.get(reverse('pxeconfig'),
220+ self.get_default_params())
221 self.assertEqual(
222 fake_boot_purpose,
223 json.loads(response.content)["purpose"])
224
225=== modified file 'src/provisioningserver/tests/test_tftp.py'
226--- src/provisioningserver/tests/test_tftp.py 2012-08-30 10:37:26 +0000
227+++ src/provisioningserver/tests/test_tftp.py 2012-10-03 08:57:21 +0000
228@@ -70,7 +70,9 @@
229 The path is intended to match `re_config_file`, and the components are
230 the expected groups from a match.
231 """
232- components = {"mac": factory.getRandomMACAddress(b"-")}
233+ components = {"mac": factory.getRandomMACAddress(b"-"),
234+ "arch": None,
235+ "subarch": None}
236 config_path = compose_config_path(components["mac"])
237 return config_path, components
238
239@@ -112,13 +114,15 @@
240 mac = 'aa-bb-cc-dd-ee-ff'
241 match = TFTPBackend.re_config_file.match('pxelinux.cfg/01-%s' % mac)
242 self.assertIsNotNone(match)
243- self.assertEqual({'mac': mac}, match.groupdict())
244+ self.assertEqual({'mac': mac, 'arch': None, 'subarch': None},
245+ match.groupdict())
246
247 def test_re_config_file_matches_pxelinux_cfg_with_leading_slash(self):
248 mac = 'aa-bb-cc-dd-ee-ff'
249 match = TFTPBackend.re_config_file.match('/pxelinux.cfg/01-%s' % mac)
250 self.assertIsNotNone(match)
251- self.assertEqual({'mac': mac}, match.groupdict())
252+ self.assertEqual({'mac': mac, 'arch': None, 'subarch': None},
253+ match.groupdict())
254
255 def test_re_config_file_does_not_match_non_config_file(self):
256 self.assertIsNone(
257@@ -132,6 +136,29 @@
258 self.assertIsNone(
259 TFTPBackend.re_config_file.match('foo/01-aa-bb-cc-dd-ee-ff'))
260
261+ def test_re_config_file_with_default(self):
262+ match = TFTPBackend.re_config_file.match('pxelinux.cfg/default')
263+ self.assertIsNotNone(match)
264+ self.assertEqual({'mac': None, 'arch': None, 'subarch': None},
265+ match.groupdict())
266+
267+ def test_re_config_file_with_default_arch(self):
268+ arch = factory.make_name('arch', sep='')
269+ match = TFTPBackend.re_config_file.match('pxelinux.cfg/default.%s' %
270+ arch)
271+ self.assertIsNotNone(match)
272+ self.assertEqual({'mac': None, 'arch': arch, 'subarch': None},
273+ match.groupdict())
274+
275+ def test_re_config_file_with_default_arch_and_subarch(self):
276+ arch = factory.make_name('arch', sep='')
277+ subarch = factory.make_name('subarch', sep='')
278+ match = TFTPBackend.re_config_file.match(
279+ 'pxelinux.cfg/default.%s-%s' % (arch, subarch))
280+ self.assertIsNotNone(match)
281+ self.assertEqual({'mac': None, 'arch': arch, 'subarch': subarch},
282+ match.groupdict())
283+
284
285 class TestTFTPBackend(TestCase):
286 """Tests for `provisioningserver.tftp.TFTPBackend`."""
287
288=== modified file 'src/provisioningserver/tftp.py'
289--- src/provisioningserver/tftp.py 2012-09-12 19:56:23 +0000
290+++ src/provisioningserver/tftp.py 2012-10-03 08:57:21 +0000
291@@ -14,6 +14,7 @@
292 "TFTPBackend",
293 ]
294
295+import httplib
296 from io import BytesIO
297 from itertools import repeat
298 import json
299@@ -32,7 +33,9 @@
300 FilesystemSynchronousBackend,
301 IReader,
302 )
303+from tftp.errors import FileNotFound
304 from twisted.web.client import getPage
305+import twisted.web.error
306 from zope.interface import implementer
307
308
309@@ -89,9 +92,18 @@
310 ^/*
311 pxelinux[.]cfg # PXELINUX expects this.
312 /
313- {htype:02x} # ARP HTYPE.
314- -
315- (?P<mac>{re_mac_address.pattern}) # Capture MAC.
316+ (?: # either a MAC
317+ {htype:02x} # ARP HTYPE.
318+ -
319+ (?P<mac>{re_mac_address.pattern}) # Capture MAC.
320+ | # or "default"
321+ default
322+ (?: # perhaps with specified arch, with a separator of either '-'
323+ # or '.', since the spec was changed and both are unambiguous
324+ [.-](?P<arch>\w+) # arch
325+ (?:-(?P<subarch>\w+))? # optional subarch
326+ )?
327+ )
328 $
329 '''.format(
330 htype=ARP_HTYPE.ETHERNET,
331@@ -162,6 +174,25 @@
332 d.addCallback(BytesReader)
333 return d
334
335+ @staticmethod
336+ def get_page_errback(failure, file_name):
337+ failure.trap(twisted.web.error.Error)
338+ # This twisted.web.error.Error.status object ends up being a
339+ # string for some reason, but the constants we can compare against
340+ # (both in httplib and twisted.web.http) are ints.
341+ try:
342+ status_int = int(failure.value.status)
343+ except ValueError:
344+ # Assume that it's some other error and propagate it
345+ return failure
346+
347+ if status_int == httplib.NO_CONTENT:
348+ # Convert HTTP No Content to a TFTP file not found
349+ raise FileNotFound(file_name)
350+ else:
351+ # Otherwise propogate the unknown error
352+ return failure
353+
354 @deferred
355 def get_reader(self, file_name):
356 """See `IBackend.get_reader()`.
357@@ -174,5 +205,12 @@
358 if config_file_match is None:
359 return super(TFTPBackend, self).get_reader(file_name)
360 else:
361- params = config_file_match.groupdict()
362- return self.get_config_reader(params)
363+ # Do not include any element that has not matched (ie. is None)
364+ params = {
365+ key: value
366+ for key, value in config_file_match.groupdict().items()
367+ if value is not None
368+ }
369+ d = self.get_config_reader(params)
370+ d.addErrback(self.get_page_errback, file_name)
371+ return d