← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~racb/maas/arch-detect into lp:maas

 

Robie Basak has proposed merging lp:~racb/maas/arch-detect into lp:maas.

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>.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1041092 in MAAS: "Add netboot architecture detection without using DHCP"
  https://bugs.launchpad.net/maas/+bug/1041092

For more details, see:
https://code.launchpad.net/~racb/maas/arch-detect/+merge/127458
-- 
https://code.launchpad.net/~racb/maas/arch-detect/+merge/127458
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~racb/maas/arch-detect into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-10-02 10:12:12 +0000
+++ src/maasserver/api.py	2012-10-02 11:00:28 +0000
@@ -1492,33 +1492,90 @@
         return "poweroff"
 
 
+def get_node_from_mac_string(mac_string):
+    """Get a Node object from a MAC address string.
+
+    Returns a Node object or None if no node with the given MAC address exists.
+
+    :param mac_string: MAC address string in the form "12-34-56-78-9a-bc"
+    :return: Node object or None
+    """
+    if mac_string is None:
+        return None
+    macaddress = get_one(MACAddress.objects.filter(mac_address=mac_string))
+    if macaddress:
+        return macaddress.node
+    else:
+        return None
+
+
 def pxeconfig(request):
     """Get the PXE configuration given a node's details.
 
     Returns a JSON object corresponding to a
     :class:`provisioningserver.kernel_opts.KernelParameters` instance.
 
+    This is now fairly decoupled from pxelinux's TFTP filename encoding
+    mechanism, with one notable exception. Call this function with (mac, arch,
+    subarch) and it will do the right thing. If details it needs are missing
+    (ie. arch/subarch missing when the MAC is supplied but unknown), then it
+    will as an exception return an HTTP NO_CONTENT (204) in the expectation
+    that this will be translated to a TFTP file not found and pxelinux (or an
+    emulator) will fall back to default.<arch>-<subarch> (in the case of an
+    alternate architecture emulator) or just straight to default (in the case
+    of native pxelinux on i386 or amd64). See bug 1041092 for details and
+    discussion.
+
     :param mac: MAC address to produce a boot configuration for.
+    :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
+                 'armhf').
+    :param subarch: Subarchitecture name (in the pxelinux namespace).
     """
-    mac = get_mandatory_param(request.GET, 'mac')
+    node = get_node_from_mac_string(request.GET.get('mac', None))
 
-    macaddress = get_one(MACAddress.objects.filter(mac_address=mac))
-    if macaddress is None:
-        # Default to i386 as a works-for-all solution. This will not support
-        # non-x86 architectures, but for now this assumption holds.
-        node = None
-        arch, subarch = ARCHITECTURE.i386.split('/')
-        preseed_url = compose_enlistment_preseed_url()
-        hostname = 'maas-enlist'
-        domain = Config.objects.get_config('enlistment_domain')
-    else:
-        node = macaddress.node
+    if node:
         arch, subarch = node.architecture.split('/')
         preseed_url = compose_preseed_url(node)
         # The node's hostname may include a domain, but we ignore that
         # and use the one from the nodegroup instead.
         hostname = node.hostname.split('.', 1)[0]
         domain = node.nodegroup.name
+    else:
+        try:
+            pxelinux_arch = request.GET['arch']
+        except KeyError:
+            if 'mac' in request.GET:
+                # Request was pxelinux.cfg/01-<mac>, so attempt fall back
+                # to pxelinux.cfg/default-<arch> for arch detection.
+                return HttpResponse(status=httplib.NO_CONTENT)
+            else:
+                # Request has already fallen back, so if arch is still not
+                # provided then use i386.
+                arch = ARCHITECTURE.i386.split('/')[0]
+        else:
+            # Map from pxelinux namespace architecture names to MAAS namespace
+            # architecture names. If this gets bigger, an external lookup table
+            # would make sense. But here is fine for something as trivial as it
+            # is right now.
+            if pxelinux_arch == 'arm':
+                arch = 'armhf'
+            else:
+                arch = pxelinux_arch
+
+        # Use subarch if supplied; otherwise assume 'generic'.
+        try:
+            pxelinux_subarch = request.GET['subarch']
+        except KeyError:
+            subarch = 'generic'
+        else:
+            # Map from pxelinux namespace subarchitecture names to MAAS
+            # namespace subarchitecture names. Right now this happens to be a
+            # 1-1 mapping.
+            subarch = pxelinux_subarch
+
+        preseed_url = compose_enlistment_preseed_url()
+        hostname = 'maas-enlist'
+        domain = Config.objects.get_config('enlistment_domain')
 
     if node is None or node.status == NODE_STATUS.COMMISSIONING:
         series = Config.objects.get_config('commissioning_distro_series')

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-10-02 10:12:12 +0000
+++ src/maasserver/tests/test_api.py	2012-10-02 11:00:28 +0000
@@ -2715,21 +2715,22 @@
 
 class TestPXEConfigAPI(AnonAPITestCase):
 
-    def get_params(self):
+    def get_mac_params(self):
         return {'mac': factory.make_mac_address().mac_address}
 
-    def get_optional_params(self):
-        return ['mac']
+    def get_default_params(self):
+        return dict()
 
     def get_pxeconfig(self, params=None):
         """Make a request to `pxeconfig`, and return its response dict."""
         if params is None:
-            params = self.get_params()
+            params = self.get_default_params()
         response = self.client.get(reverse('pxeconfig'), params)
         return json.loads(response.content)
 
     def test_pxeconfig_returns_json(self):
-        response = self.client.get(reverse('pxeconfig'), self.get_params())
+        response = self.client.get(reverse('pxeconfig'),
+                                   self.get_default_params())
         self.assertThat(
             (
                 response.status_code,
@@ -2751,7 +2752,26 @@
             self.get_pxeconfig(),
             ContainsAll(KernelParameters._fields))
 
-    def test_pxeconfig_defaults_to_i386_when_node_unknown(self):
+    def test_pxeconfig_returns_data_for_known_node(self):
+        params = self.get_mac_params()
+        node = MACAddress.objects.get(mac_address=params['mac']).node
+        response = self.client.get(reverse('pxeconfig'), params)
+        self.assertEqual(httplib.OK, response.status_code)
+
+    def test_pxeconfig_returns_no_content_for_unknown_node(self):
+        params = dict(mac=factory.getRandomMACAddress(delimiter=b'-'))
+        response = self.client.get(reverse('pxeconfig'), params)
+        self.assertEqual(httplib.NO_CONTENT, response.status_code)
+
+    def test_pxeconfig_returns_data_for_detailed_but_unknown_node(self):
+        architecture = factory.getRandomEnum(ARCHITECTURE)
+        arch, subarch = architecture.split('/')
+        params = dict(mac=factory.getRandomMACAddress(delimiter=b'-'),
+                      arch=arch, subarch=subarch)
+        response = self.client.get(reverse('pxeconfig'), params)
+        self.assertEqual(httplib.OK, response.status_code)
+
+    def test_pxeconfig_defaults_to_i386_for_default(self):
         # As a lowest-common-denominator, i386 is chosen when the node is not
         # yet known to MAAS.
         expected_arch = tuple(ARCHITECTURE.i386.split('/'))
@@ -2760,16 +2780,12 @@
         self.assertEqual(expected_arch, observed_arch)
 
     def test_pxeconfig_uses_fixed_hostname_for_enlisting_node(self):
-        new_mac = factory.getRandomMACAddress()
-        self.assertEqual(
-            'maas-enlist',
-            self.get_pxeconfig({'mac': new_mac}).get('hostname'))
+        self.assertEqual('maas-enlist', self.get_pxeconfig().get('hostname'))
 
     def test_pxeconfig_uses_enlistment_domain_for_enlisting_node(self):
-        new_mac = factory.getRandomMACAddress()
         self.assertEqual(
             Config.objects.get_config('enlistment_domain'),
-            self.get_pxeconfig({'mac': new_mac}).get('domain'))
+            self.get_pxeconfig().get('domain'))
 
     def test_pxeconfig_splits_domain_from_node_hostname(self):
         host = factory.make_name('host')
@@ -2800,24 +2816,16 @@
             kernel_opts, 'get_ephemeral_name',
             FakeMethod(result=factory.getRandomString()))
 
-    def test_pxeconfig_requires_mac_address(self):
-        # The `mac` parameter is mandatory.
-        self.silence_get_ephemeral_name()
-        self.assertEqual(
-            httplib.BAD_REQUEST,
-            self.get_without_param("mac").status_code)
-
-    def test_pxeconfig_has_enlistment_preseed_url_for_unknown_node(self):
-        self.silence_get_ephemeral_name()
-        params = self.get_params()
-        params['mac'] = factory.getRandomMACAddress()
+    def test_pxeconfig_has_enlistment_preseed_url_for_default(self):
+        self.silence_get_ephemeral_name()
+        params = self.get_default_params()
         response = self.client.get(reverse('pxeconfig'), params)
         self.assertEqual(
             compose_enlistment_preseed_url(),
             json.loads(response.content)["preseed_url"])
 
     def test_pxeconfig_has_preseed_url_for_known_node(self):
-        params = self.get_params()
+        params = self.get_mac_params()
         node = MACAddress.objects.get(mac_address=params['mac']).node
         response = self.client.get(reverse('pxeconfig'), params)
         self.assertEqual(
@@ -2852,7 +2860,8 @@
     def test_pxeconfig_uses_boot_purpose(self):
         fake_boot_purpose = factory.make_name("purpose")
         self.patch(api, "get_boot_purpose", lambda node: fake_boot_purpose)
-        response = self.client.get(reverse('pxeconfig'), self.get_params())
+        response = self.client.get(reverse('pxeconfig'),
+                                   self.get_default_params())
         self.assertEqual(
             fake_boot_purpose,
             json.loads(response.content)["purpose"])

=== modified file 'src/provisioningserver/tests/test_tftp.py'
--- src/provisioningserver/tests/test_tftp.py	2012-08-30 10:37:26 +0000
+++ src/provisioningserver/tests/test_tftp.py	2012-10-02 11:00:28 +0000
@@ -70,7 +70,9 @@
         The path is intended to match `re_config_file`, and the components are
         the expected groups from a match.
         """
-        components = {"mac": factory.getRandomMACAddress(b"-")}
+        components = {"mac": factory.getRandomMACAddress(b"-"),
+                      "arch": None,
+                      "subarch": None}
         config_path = compose_config_path(components["mac"])
         return config_path, components
 
@@ -112,13 +114,15 @@
         mac = 'aa-bb-cc-dd-ee-ff'
         match = TFTPBackend.re_config_file.match('pxelinux.cfg/01-%s' % mac)
         self.assertIsNotNone(match)
-        self.assertEqual({'mac': mac}, match.groupdict())
+        self.assertEqual({'mac': mac, 'arch': None, 'subarch': None},
+                         match.groupdict())
 
     def test_re_config_file_matches_pxelinux_cfg_with_leading_slash(self):
         mac = 'aa-bb-cc-dd-ee-ff'
         match = TFTPBackend.re_config_file.match('/pxelinux.cfg/01-%s' % mac)
         self.assertIsNotNone(match)
-        self.assertEqual({'mac': mac}, match.groupdict())
+        self.assertEqual({'mac': mac, 'arch': None, 'subarch': None},
+                         match.groupdict())
 
     def test_re_config_file_does_not_match_non_config_file(self):
         self.assertIsNone(
@@ -132,6 +136,29 @@
         self.assertIsNone(
             TFTPBackend.re_config_file.match('foo/01-aa-bb-cc-dd-ee-ff'))
 
+    def test_re_config_file_with_default(self):
+        match = TFTPBackend.re_config_file.match('pxelinux.cfg/default')
+        self.assertIsNotNone(match)
+        self.assertEqual({'mac': None, 'arch': None, 'subarch': None},
+            match.groupdict())
+
+    def test_re_config_file_with_default_arch(self):
+        arch = factory.make_name('arch', sep='')
+        match = TFTPBackend.re_config_file.match('pxelinux.cfg/default.%s' %
+                                                 arch)
+        self.assertIsNotNone(match)
+        self.assertEqual({'mac': None, 'arch': arch, 'subarch': None},
+            match.groupdict())
+
+    def test_re_config_file_with_default_arch_and_subarch(self):
+        arch = factory.make_name('arch', sep='')
+        subarch = factory.make_name('subarch', sep='')
+        match = TFTPBackend.re_config_file.match(
+            'pxelinux.cfg/default.%s-%s' % (arch, subarch))
+        self.assertIsNotNone(match)
+        self.assertEqual({'mac': None, 'arch': arch, 'subarch': subarch},
+            match.groupdict())
+
 
 class TestTFTPBackend(TestCase):
     """Tests for `provisioningserver.tftp.TFTPBackend`."""

=== modified file 'src/provisioningserver/tftp.py'
--- src/provisioningserver/tftp.py	2012-09-12 19:56:23 +0000
+++ src/provisioningserver/tftp.py	2012-10-02 11:00:28 +0000
@@ -14,6 +14,7 @@
     "TFTPBackend",
     ]
 
+import httplib
 from io import BytesIO
 from itertools import repeat
 import json
@@ -32,7 +33,9 @@
     FilesystemSynchronousBackend,
     IReader,
     )
+from tftp.errors import FileNotFound
 from twisted.web.client import getPage
+import twisted.web.error
 from zope.interface import implementer
 
 
@@ -89,9 +92,17 @@
         ^/*
         pxelinux[.]cfg    # PXELINUX expects this.
         /
-        {htype:02x}    # ARP HTYPE.
-        -
-        (?P<mac>{re_mac_address.pattern})    # Capture MAC.
+        ( # either a MAC
+            {htype:02x}    # ARP HTYPE.
+            -
+            (?P<mac>{re_mac_address.pattern})    # Capture MAC.
+        | # or "default"
+            default
+              ( # perhaps with specified arch
+                [.-](?P<arch>\w+) # arch
+                (-(?P<subarch>\w+))? # optional subarch
+              )?
+        )
         $
         '''.format(
             htype=ARP_HTYPE.ETHERNET,
@@ -162,6 +173,25 @@
         d.addCallback(BytesReader)
         return d
 
+    @staticmethod
+    def get_page_errback(failure, file_name):
+        failure.trap(twisted.web.error.Error)
+        # This twisted.web.error.Error.status object ends up being a
+        # string for some reason, but the constants we can compare against
+        # (both in httplib and twisted.web.http) are ints.
+        try:
+            status_int = int(failure.value.status)
+        except ValueError:
+            # Assume that it's some other error and propagate it
+            return failure
+
+        if status_int == httplib.NO_CONTENT:
+            # Convert HTTP No Content to a TFTP file not found
+            raise FileNotFound(file_name)
+        else:
+            # Otherwise propogate the unknown error
+            return failure
+
     @deferred
     def get_reader(self, file_name):
         """See `IBackend.get_reader()`.
@@ -174,5 +204,10 @@
         if config_file_match is None:
             return super(TFTPBackend, self).get_reader(file_name)
         else:
-            params = config_file_match.groupdict()
-            return self.get_config_reader(params)
+            # Do not include any element that has not matched (ie. is None)
+            params = {k: v
+                      for k, v in config_file_match.groupdict().iteritems()
+                      if v is not None}
+            d = self.get_config_reader(params)
+            d.addErrback(self.get_page_errback, file_name)
+            return d