Merge lp:~jtv/maas/extract-pxeconfig into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 2736
Proposed branch: lp:~jtv/maas/extract-pxeconfig
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 554 lines (+251/-213)
4 files modified
src/maasserver/api/api.py (+0/-209)
src/maasserver/api/pxeconfig.py (+247/-0)
src/maasserver/api/tests/test_pxeconfig.py (+3/-3)
src/maasserver/urls_api.py (+1/-1)
To merge this branch: bzr merge lp:~jtv/maas/extract-pxeconfig
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+231111@code.launchpad.net

Commit message

Extract API handler: pxeconfig.

Description of the change

For self-approval after cursory inspection.

Jeroen

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Looks OK. Self-approving.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/api/api.py'
--- src/maasserver/api/api.py 2014-08-17 01:43:43 +0000
+++ src/maasserver/api/api.py 2014-08-17 02:07:38 +0000
@@ -69,7 +69,6 @@
69 "NodeMacHandler",69 "NodeMacHandler",
70 "NodeMacsHandler",70 "NodeMacsHandler",
71 "NodesHandler",71 "NodesHandler",
72 "pxeconfig",
73 "render_api_docs",72 "render_api_docs",
74 "store_node_power_parameters",73 "store_node_power_parameters",
75 ]74 ]
@@ -127,7 +126,6 @@
127 NODE_PERMISSION,126 NODE_PERMISSION,
128 NODE_STATUS,127 NODE_STATUS,
129 NODEGROUP_STATUS,128 NODEGROUP_STATUS,
130 PRESEED_TYPE,
131 )129 )
132from maasserver.exceptions import (130from maasserver.exceptions import (
133 MAASAPIBadRequest,131 MAASAPIBadRequest,
@@ -156,7 +154,6 @@
156 validate_config_name,154 validate_config_name,
157 )155 )
158from maasserver.models import (156from maasserver.models import (
159 BootImage,
160 Config,157 Config,
161 DHCPLease,158 DHCPLease,
162 MACAddress,159 MACAddress,
@@ -172,18 +169,10 @@
172 )169 )
173from maasserver.node_action import Commission170from maasserver.node_action import Commission
174from maasserver.node_constraint_filter_forms import AcquireNodeForm171from maasserver.node_constraint_filter_forms import AcquireNodeForm
175from maasserver.preseed import (
176 compose_enlistment_preseed_url,
177 compose_preseed_url,
178 get_preseed_type_for,
179 )
180from maasserver.server_address import get_maas_facing_server_address
181from maasserver.third_party_drivers import get_third_party_driver
182from maasserver.utils import (172from maasserver.utils import (
183 build_absolute_uri,173 build_absolute_uri,
184 find_nodegroup,174 find_nodegroup,
185 get_local_cluster_UUID,175 get_local_cluster_UUID,
186 strip_domain,
187 )176 )
188from maasserver.utils.orm import (177from maasserver.utils.orm import (
189 get_first,178 get_first,
@@ -192,7 +181,6 @@
192from metadataserver.models import NodeResult181from metadataserver.models import NodeResult
193import netaddr182import netaddr
194from piston.utils import rc183from piston.utils import rc
195from provisioningserver.kernel_opts import KernelParameters
196from provisioningserver.logger import get_maas_logger184from provisioningserver.logger import get_maas_logger
197from provisioningserver.power_schema import UNKNOWN_POWER_TYPE185from provisioningserver.power_schema import UNKNOWN_POWER_TYPE
198import simplejson as json186import simplejson as json
@@ -1910,203 +1898,6 @@
1910 context_instance=RequestContext(request))1898 context_instance=RequestContext(request))
19111899
19121900
1913def get_boot_purpose(node):
1914 """Return a suitable "purpose" for this boot, e.g. "install"."""
1915 # XXX: allenap bug=1031406 2012-07-31: The boot purpose is still in
1916 # flux. It may be that there will just be an "ephemeral" environment and
1917 # an "install" environment, and the differing behaviour between, say,
1918 # enlistment and commissioning - both of which will use the "ephemeral"
1919 # environment - will be governed by varying the preseed or PXE
1920 # configuration.
1921 if node is None:
1922 # This node is enlisting, for which we use a commissioning image.
1923 return "commissioning"
1924 elif node.status == NODE_STATUS.COMMISSIONING:
1925 # It is commissioning.
1926 return "commissioning"
1927 elif node.status == NODE_STATUS.ALLOCATED:
1928 # Install the node if netboot is enabled, otherwise boot locally.
1929 if node.netboot:
1930 preseed_type = get_preseed_type_for(node)
1931 if preseed_type == PRESEED_TYPE.CURTIN:
1932 return "xinstall"
1933 else:
1934 return "install"
1935 else:
1936 return "local" # TODO: Investigate.
1937 else:
1938 # Just poweroff? TODO: Investigate. Perhaps even send an IPMI signal
1939 # to turn off power.
1940 return "poweroff"
1941
1942
1943def get_node_from_mac_string(mac_string):
1944 """Get a Node object from a MAC address string.
1945
1946 Returns a Node object or None if no node with the given MAC address exists.
1947
1948 :param mac_string: MAC address string in the form "12-34-56-78-9a-bc"
1949 :return: Node object or None
1950 """
1951 if mac_string is None:
1952 return None
1953 macaddress = get_one(MACAddress.objects.filter(mac_address=mac_string))
1954 return macaddress.node if macaddress else None
1955
1956
1957def find_nodegroup_for_pxeconfig_request(request):
1958 """Find the nodegroup responsible for a `pxeconfig` request.
1959
1960 Looks for the `cluster_uuid` parameter in the request. If there is
1961 none, figures it out based on the requesting IP as a compatibility
1962 measure. In that case, the result may be incorrect.
1963 """
1964 uuid = request.GET.get('cluster_uuid', None)
1965 if uuid is None:
1966 return find_nodegroup(request)
1967 else:
1968 return NodeGroup.objects.get(uuid=uuid)
1969
1970
1971def pxeconfig(request):
1972 """Get the PXE configuration given a node's details.
1973
1974 Returns a JSON object corresponding to a
1975 :class:`provisioningserver.kernel_opts.KernelParameters` instance.
1976
1977 This is now fairly decoupled from pxelinux's TFTP filename encoding
1978 mechanism, with one notable exception. Call this function with (mac, arch,
1979 subarch) and it will do the right thing. If details it needs are missing
1980 (ie. arch/subarch missing when the MAC is supplied but unknown), then it
1981 will as an exception return an HTTP NO_CONTENT (204) in the expectation
1982 that this will be translated to a TFTP file not found and pxelinux (or an
1983 emulator) will fall back to default-<arch>-<subarch> (in the case of an
1984 alternate architecture emulator) or just straight to default (in the case
1985 of native pxelinux on i386 or amd64). See bug 1041092 for details and
1986 discussion.
1987
1988 :param mac: MAC address to produce a boot configuration for.
1989 :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
1990 'armhf').
1991 :param subarch: Subarchitecture name (in the pxelinux namespace).
1992 :param local: The IP address of the cluster controller.
1993 :param remote: The IP address of the booting node.
1994 :param cluster_uuid: UUID of the cluster responsible for this node.
1995 If omitted, the call will attempt to figure it out based on the
1996 requesting IP address, for compatibility. Passing `cluster_uuid`
1997 is preferred.
1998 """
1999 node = get_node_from_mac_string(request.GET.get('mac', None))
2000
2001 if node is None or node.status == NODE_STATUS.COMMISSIONING:
2002 osystem = Config.objects.get_config('commissioning_osystem')
2003 series = Config.objects.get_config('commissioning_distro_series')
2004 else:
2005 osystem = node.get_osystem()
2006 series = node.get_distro_series()
2007
2008 if node:
2009 arch, subarch = node.architecture.split('/')
2010 preseed_url = compose_preseed_url(node)
2011 # The node's hostname may include a domain, but we ignore that
2012 # and use the one from the nodegroup instead.
2013 hostname = strip_domain(node.hostname)
2014 nodegroup = node.nodegroup
2015 domain = nodegroup.name
2016 else:
2017 nodegroup = find_nodegroup_for_pxeconfig_request(request)
2018 preseed_url = compose_enlistment_preseed_url(nodegroup=nodegroup)
2019 hostname = 'maas-enlist'
2020 domain = Config.objects.get_config('enlistment_domain')
2021
2022 arch = get_optional_param(request.GET, 'arch')
2023 if arch is None:
2024 if 'mac' in request.GET:
2025 # Request was pxelinux.cfg/01-<mac>, so attempt fall back
2026 # to pxelinux.cfg/default-<arch>-<subarch> for arch detection.
2027 return HttpResponse(status=httplib.NO_CONTENT)
2028 else:
2029 # Look in BootImage for an image that actually exists for the
2030 # current series. If nothing is found, fall back to i386 like
2031 # we used to. LP #1181334
2032 image = BootImage.objects.get_default_arch_image_in_nodegroup(
2033 nodegroup, osystem, series, purpose='commissioning')
2034 if image is None:
2035 arch = 'i386'
2036 else:
2037 arch = image.architecture
2038
2039 subarch = get_optional_param(request.GET, 'subarch', 'generic')
2040
2041 # If we are booting with "xinstall", then we should always return the
2042 # commissioning operating system and distro_series.
2043 purpose = get_boot_purpose(node)
2044 if purpose == "xinstall":
2045 osystem = Config.objects.get_config('commissioning_osystem')
2046 series = Config.objects.get_config('commissioning_distro_series')
2047
2048 # We use as our default label the label of the most recent image for
2049 # the criteria we've assembled above. If there is no latest image
2050 # (which should never happen in reality but may happen in tests), we
2051 # fall back to using 'no-such-image' as our default.
2052 latest_image = BootImage.objects.get_latest_image(
2053 nodegroup, osystem, arch, subarch, series, purpose)
2054 if latest_image is None:
2055 # XXX 2014-03-18 gmb bug=1294131:
2056 # We really ought to raise an exception here so that client
2057 # and server can handle it according to their needs. At the
2058 # moment, though, that breaks too many tests in awkward
2059 # ways.
2060 latest_label = 'no-such-image'
2061 else:
2062 latest_label = latest_image.label
2063 # subarch may be different from the request because newer images
2064 # support older hardware enablement, e.g. trusty/generic
2065 # supports trusty/hwe-s. We must override the subarch to the one
2066 # on the image otherwise the config path will be wrong if
2067 # get_latest_image() returned an image with a different subarch.
2068 subarch = latest_image.subarchitecture
2069 label = get_optional_param(request.GET, 'label', latest_label)
2070
2071 if node is not None:
2072 # We don't care if the kernel opts is from the global setting or a tag,
2073 # just get the options
2074 _, effective_kernel_opts = node.get_effective_kernel_options()
2075
2076 # Add any extra options from a third party driver.
2077 use_driver = Config.objects.get_config('enable_third_party_drivers')
2078 if use_driver:
2079 driver = get_third_party_driver(node)
2080 driver_kernel_opts = driver.get('kernel_opts', '')
2081
2082 combined_opts = ('%s %s' % (
2083 '' if effective_kernel_opts is None else effective_kernel_opts,
2084 driver_kernel_opts)).strip()
2085 if len(combined_opts):
2086 extra_kernel_opts = combined_opts
2087 else:
2088 extra_kernel_opts = None
2089 else:
2090 extra_kernel_opts = effective_kernel_opts
2091 else:
2092 # If there's no node defined then we must be enlisting here, but
2093 # we still need to return the global kernel options.
2094 extra_kernel_opts = Config.objects.get_config("kernel_opts")
2095
2096 server_address = get_maas_facing_server_address(nodegroup=nodegroup)
2097 cluster_address = get_mandatory_param(request.GET, "local")
2098
2099 params = KernelParameters(
2100 osystem=osystem, arch=arch, subarch=subarch, release=series,
2101 label=label, purpose=purpose, hostname=hostname, domain=domain,
2102 preseed_url=preseed_url, log_host=server_address,
2103 fs_host=cluster_address, extra_opts=extra_kernel_opts)
2104
2105 return HttpResponse(
2106 json.dumps(params._asdict()),
2107 content_type="application/json")
2108
2109
2110class CommissioningResultsHandler(OperationsHandler):1901class CommissioningResultsHandler(OperationsHandler):
2111 """Read the collection of NodeResult in the MAAS."""1902 """Read the collection of NodeResult in the MAAS."""
2112 api_doc_section_name = "Commissioning results"1903 api_doc_section_name = "Commissioning results"
21131904
=== added file 'src/maasserver/api/pxeconfig.py'
--- src/maasserver/api/pxeconfig.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/api/pxeconfig.py 2014-08-17 02:07:38 +0000
@@ -0,0 +1,247 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""API handler: `pxeconfig`."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = [
16 'pxeconfig',
17 ]
18
19
20import httplib
21
22from django.http import HttpResponse
23from maasserver.api.utils import (
24 get_mandatory_param,
25 get_optional_param,
26 )
27from maasserver.enum import (
28 NODE_STATUS,
29 PRESEED_TYPE,
30 )
31from maasserver.models import (
32 BootImage,
33 Config,
34 MACAddress,
35 NodeGroup,
36 )
37from maasserver.preseed import (
38 compose_enlistment_preseed_url,
39 compose_preseed_url,
40 get_preseed_type_for,
41 )
42from maasserver.server_address import get_maas_facing_server_address
43from maasserver.third_party_drivers import get_third_party_driver
44from maasserver.utils import (
45 find_nodegroup,
46 strip_domain,
47 )
48from maasserver.utils.orm import get_one
49from provisioningserver.kernel_opts import KernelParameters
50import simplejson as json
51
52
53def find_nodegroup_for_pxeconfig_request(request):
54 """Find the nodegroup responsible for a `pxeconfig` request.
55
56 Looks for the `cluster_uuid` parameter in the request. If there is
57 none, figures it out based on the requesting IP as a compatibility
58 measure. In that case, the result may be incorrect.
59 """
60 uuid = request.GET.get('cluster_uuid', None)
61 if uuid is None:
62 return find_nodegroup(request)
63 else:
64 return NodeGroup.objects.get(uuid=uuid)
65
66
67def get_node_from_mac_string(mac_string):
68 """Get a Node object from a MAC address string.
69
70 Returns a Node object or None if no node with the given MAC address exists.
71
72 :param mac_string: MAC address string in the form "12-34-56-78-9a-bc"
73 :return: Node object or None
74 """
75 if mac_string is None:
76 return None
77 macaddress = get_one(MACAddress.objects.filter(mac_address=mac_string))
78 return macaddress.node if macaddress else None
79
80
81def get_boot_purpose(node):
82 """Return a suitable "purpose" for this boot, e.g. "install"."""
83 # XXX: allenap bug=1031406 2012-07-31: The boot purpose is still in
84 # flux. It may be that there will just be an "ephemeral" environment and
85 # an "install" environment, and the differing behaviour between, say,
86 # enlistment and commissioning - both of which will use the "ephemeral"
87 # environment - will be governed by varying the preseed or PXE
88 # configuration.
89 if node is None:
90 # This node is enlisting, for which we use a commissioning image.
91 return "commissioning"
92 elif node.status == NODE_STATUS.COMMISSIONING:
93 # It is commissioning.
94 return "commissioning"
95 elif node.status == NODE_STATUS.ALLOCATED:
96 # Install the node if netboot is enabled, otherwise boot locally.
97 if node.netboot:
98 preseed_type = get_preseed_type_for(node)
99 if preseed_type == PRESEED_TYPE.CURTIN:
100 return "xinstall"
101 else:
102 return "install"
103 else:
104 return "local" # TODO: Investigate.
105 else:
106 # Just poweroff? TODO: Investigate. Perhaps even send an IPMI signal
107 # to turn off power.
108 return "poweroff"
109
110
111def pxeconfig(request):
112 """Get the PXE configuration given a node's details.
113
114 Returns a JSON object corresponding to a
115 :class:`provisioningserver.kernel_opts.KernelParameters` instance.
116
117 This is now fairly decoupled from pxelinux's TFTP filename encoding
118 mechanism, with one notable exception. Call this function with (mac, arch,
119 subarch) and it will do the right thing. If details it needs are missing
120 (ie. arch/subarch missing when the MAC is supplied but unknown), then it
121 will as an exception return an HTTP NO_CONTENT (204) in the expectation
122 that this will be translated to a TFTP file not found and pxelinux (or an
123 emulator) will fall back to default-<arch>-<subarch> (in the case of an
124 alternate architecture emulator) or just straight to default (in the case
125 of native pxelinux on i386 or amd64). See bug 1041092 for details and
126 discussion.
127
128 :param mac: MAC address to produce a boot configuration for.
129 :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
130 'armhf').
131 :param subarch: Subarchitecture name (in the pxelinux namespace).
132 :param local: The IP address of the cluster controller.
133 :param remote: The IP address of the booting node.
134 :param cluster_uuid: UUID of the cluster responsible for this node.
135 If omitted, the call will attempt to figure it out based on the
136 requesting IP address, for compatibility. Passing `cluster_uuid`
137 is preferred.
138 """
139 node = get_node_from_mac_string(request.GET.get('mac', None))
140
141 if node is None or node.status == NODE_STATUS.COMMISSIONING:
142 osystem = Config.objects.get_config('commissioning_osystem')
143 series = Config.objects.get_config('commissioning_distro_series')
144 else:
145 osystem = node.get_osystem()
146 series = node.get_distro_series()
147
148 if node:
149 arch, subarch = node.architecture.split('/')
150 preseed_url = compose_preseed_url(node)
151 # The node's hostname may include a domain, but we ignore that
152 # and use the one from the nodegroup instead.
153 hostname = strip_domain(node.hostname)
154 nodegroup = node.nodegroup
155 domain = nodegroup.name
156 else:
157 nodegroup = find_nodegroup_for_pxeconfig_request(request)
158 preseed_url = compose_enlistment_preseed_url(nodegroup=nodegroup)
159 hostname = 'maas-enlist'
160 domain = Config.objects.get_config('enlistment_domain')
161
162 arch = get_optional_param(request.GET, 'arch')
163 if arch is None:
164 if 'mac' in request.GET:
165 # Request was pxelinux.cfg/01-<mac>, so attempt fall back
166 # to pxelinux.cfg/default-<arch>-<subarch> for arch detection.
167 return HttpResponse(status=httplib.NO_CONTENT)
168 else:
169 # Look in BootImage for an image that actually exists for the
170 # current series. If nothing is found, fall back to i386 like
171 # we used to. LP #1181334
172 image = BootImage.objects.get_default_arch_image_in_nodegroup(
173 nodegroup, osystem, series, purpose='commissioning')
174 if image is None:
175 arch = 'i386'
176 else:
177 arch = image.architecture
178
179 subarch = get_optional_param(request.GET, 'subarch', 'generic')
180
181 # If we are booting with "xinstall", then we should always return the
182 # commissioning operating system and distro_series.
183 purpose = get_boot_purpose(node)
184 if purpose == "xinstall":
185 osystem = Config.objects.get_config('commissioning_osystem')
186 series = Config.objects.get_config('commissioning_distro_series')
187
188 # We use as our default label the label of the most recent image for
189 # the criteria we've assembled above. If there is no latest image
190 # (which should never happen in reality but may happen in tests), we
191 # fall back to using 'no-such-image' as our default.
192 latest_image = BootImage.objects.get_latest_image(
193 nodegroup, osystem, arch, subarch, series, purpose)
194 if latest_image is None:
195 # XXX 2014-03-18 gmb bug=1294131:
196 # We really ought to raise an exception here so that client
197 # and server can handle it according to their needs. At the
198 # moment, though, that breaks too many tests in awkward
199 # ways.
200 latest_label = 'no-such-image'
201 else:
202 latest_label = latest_image.label
203 # subarch may be different from the request because newer images
204 # support older hardware enablement, e.g. trusty/generic
205 # supports trusty/hwe-s. We must override the subarch to the one
206 # on the image otherwise the config path will be wrong if
207 # get_latest_image() returned an image with a different subarch.
208 subarch = latest_image.subarchitecture
209 label = get_optional_param(request.GET, 'label', latest_label)
210
211 if node is not None:
212 # We don't care if the kernel opts is from the global setting or a tag,
213 # just get the options
214 _, effective_kernel_opts = node.get_effective_kernel_options()
215
216 # Add any extra options from a third party driver.
217 use_driver = Config.objects.get_config('enable_third_party_drivers')
218 if use_driver:
219 driver = get_third_party_driver(node)
220 driver_kernel_opts = driver.get('kernel_opts', '')
221
222 combined_opts = ('%s %s' % (
223 '' if effective_kernel_opts is None else effective_kernel_opts,
224 driver_kernel_opts)).strip()
225 if len(combined_opts):
226 extra_kernel_opts = combined_opts
227 else:
228 extra_kernel_opts = None
229 else:
230 extra_kernel_opts = effective_kernel_opts
231 else:
232 # If there's no node defined then we must be enlisting here, but
233 # we still need to return the global kernel options.
234 extra_kernel_opts = Config.objects.get_config("kernel_opts")
235
236 server_address = get_maas_facing_server_address(nodegroup=nodegroup)
237 cluster_address = get_mandatory_param(request.GET, "local")
238
239 params = KernelParameters(
240 osystem=osystem, arch=arch, subarch=subarch, release=series,
241 label=label, purpose=purpose, hostname=hostname, domain=domain,
242 preseed_url=preseed_url, log_host=server_address,
243 fs_host=cluster_address, extra_opts=extra_kernel_opts)
244
245 return HttpResponse(
246 json.dumps(params._asdict()),
247 content_type="application/json")
0248
=== modified file 'src/maasserver/api/tests/test_pxeconfig.py'
--- src/maasserver/api/tests/test_pxeconfig.py 2014-08-16 05:43:33 +0000
+++ src/maasserver/api/tests/test_pxeconfig.py 2014-08-17 02:07:38 +0000
@@ -20,8 +20,8 @@
20from django.core.urlresolvers import reverse20from django.core.urlresolvers import reverse
21from django.test.client import RequestFactory21from django.test.client import RequestFactory
22from maasserver import server_address22from maasserver import server_address
23from maasserver.api import api as api_module23from maasserver.api import pxeconfig as pxeconfig_module
24from maasserver.api.api import (24from maasserver.api.pxeconfig import (
25 find_nodegroup_for_pxeconfig_request,25 find_nodegroup_for_pxeconfig_request,
26 get_boot_purpose,26 get_boot_purpose,
27 )27 )
@@ -344,7 +344,7 @@
344 def test_pxeconfig_uses_boot_purpose(self):344 def test_pxeconfig_uses_boot_purpose(self):
345 fake_boot_purpose = factory.make_name("purpose")345 fake_boot_purpose = factory.make_name("purpose")
346 self.patch(346 self.patch(
347 api_module, "get_boot_purpose"347 pxeconfig_module, "get_boot_purpose"
348 ).return_value = fake_boot_purpose348 ).return_value = fake_boot_purpose
349 response = self.client.get(reverse('pxeconfig'),349 response = self.client.get(reverse('pxeconfig'),
350 self.get_default_params())350 self.get_default_params())
351351
=== modified file 'src/maasserver/urls_api.py'
--- src/maasserver/urls_api.py 2014-08-17 01:43:43 +0000
+++ src/maasserver/urls_api.py 2014-08-17 02:07:38 +0000
@@ -31,7 +31,6 @@
31 NodeMacHandler,31 NodeMacHandler,
32 NodeMacsHandler,32 NodeMacsHandler,
33 NodesHandler,33 NodesHandler,
34 pxeconfig,
35 VersionHandler,34 VersionHandler,
36 )35 )
37from maasserver.api.auth import api_auth36from maasserver.api.auth import api_auth
@@ -67,6 +66,7 @@
67 NodeGroupInterfaceHandler,66 NodeGroupInterfaceHandler,
68 NodeGroupInterfacesHandler,67 NodeGroupInterfacesHandler,
69 )68 )
69from maasserver.api.pxeconfig import pxeconfig
70from maasserver.api.ssh_keys import (70from maasserver.api.ssh_keys import (
71 SSHKeyHandler,71 SSHKeyHandler,
72 SSHKeysHandler,72 SSHKeysHandler,