~lutostag/ubuntu/trusty/maas/1.5.4+keystone

« back to all changes in this revision

Viewing changes to src/maasserver/api.py

  • Committer: Package Import Robot
  • Author(s): Andres Rodriguez
  • Date: 2013-03-04 11:49:44 UTC
  • mto: This revision was merged to the branch mainline in revision 25.
  • Revision ID: package-import@ubuntu.com-20130304114944-azcvu9anlf8mizpa
Tags: upstream-1.3+bzr1452+dfsg
ImportĀ upstreamĀ versionĀ 1.3+bzr1452+dfsg

Show diffs side-by-side

added added

removed removed

Lines of Context:
57
57
    "AccountHandler",
58
58
    "AnonNodeGroupsHandler",
59
59
    "AnonNodesHandler",
60
 
    "AnonymousOperationsHandler",
61
60
    "api_doc",
62
61
    "api_doc_title",
63
62
    "BootImagesHandler",
 
63
    "CommissioningScriptHandler",
 
64
    "CommissioningScriptsHandler",
 
65
    "CommissioningResultsHandler",
64
66
    "FileHandler",
65
67
    "FilesHandler",
66
68
    "get_oauth_token",
72
74
    "NodeMacHandler",
73
75
    "NodeMacsHandler",
74
76
    "NodesHandler",
75
 
    "OperationsHandler",
76
77
    "TagHandler",
77
78
    "TagsHandler",
78
79
    "pxeconfig",
95
96
import sys
96
97
from textwrap import dedent
97
98
from urlparse import urlparse
 
99
from xml.sax.saxutils import quoteattr
98
100
 
99
101
from celery.app import app_or_default
100
102
from django.conf import settings
107
109
from django.http import (
108
110
    Http404,
109
111
    HttpResponse,
110
 
    HttpResponseBadRequest,
111
 
    QueryDict,
112
112
    )
113
113
from django.shortcuts import (
114
114
    get_object_or_404,
118
118
from django.utils.http import urlquote_plus
119
119
from docutils import core
120
120
from formencode import validators
121
 
from formencode.validators import Invalid
 
121
from maasserver.api_support import (
 
122
    AnonymousOperationsHandler,
 
123
    operation,
 
124
    OperationsHandler,
 
125
    )
 
126
from maasserver.api_utils import (
 
127
    extract_oauth_key,
 
128
    get_list_from_dict_or_multidict,
 
129
    get_mandatory_param,
 
130
    get_oauth_token,
 
131
    get_optional_list,
 
132
    get_overrided_query_dict,
 
133
    )
122
134
from maasserver.apidoc import (
123
135
    describe_resource,
124
136
    find_api_resources,
179
191
    strip_domain,
180
192
    )
181
193
from maasserver.utils.orm import get_one
 
194
from metadataserver.fields import Bin
 
195
from metadataserver.models import (
 
196
    CommissioningScript,
 
197
    NodeCommissionResult,
 
198
    )
182
199
from piston.emitters import JSONEmitter
183
 
from piston.handler import (
184
 
    AnonymousBaseHandler,
185
 
    BaseHandler,
186
 
    HandlerMetaClass,
187
 
    typemapper,
188
 
    )
189
 
from piston.models import Token
190
 
from piston.resource import Resource
 
200
from piston.handler import typemapper
191
201
from piston.utils import rc
192
202
from provisioningserver.enum import POWER_TYPE
193
203
from provisioningserver.kernel_opts import KernelParameters
194
204
import simplejson as json
195
205
 
196
 
 
197
 
class OperationsResource(Resource):
198
 
    """A resource supporting operation dispatch.
199
 
 
200
 
    All requests are passed onto the handler's `dispatch` method. See
201
 
    :class:`OperationsHandler`.
202
 
    """
203
 
 
204
 
    crudmap = Resource.callmap
205
 
    callmap = dict.fromkeys(crudmap, "dispatch")
206
 
 
207
 
 
208
 
class RestrictedResource(OperationsResource):
209
 
 
210
 
    def authenticate(self, request, rm):
211
 
        actor, anonymous = super(
212
 
            RestrictedResource, self).authenticate(request, rm)
213
 
        if not anonymous and not request.user.is_active:
214
 
            raise PermissionDenied("User is not allowed access to this API.")
215
 
        else:
216
 
            return actor, anonymous
217
 
 
218
 
 
219
 
class AdminRestrictedResource(RestrictedResource):
220
 
 
221
 
    def authenticate(self, request, rm):
222
 
        actor, anonymous = super(
223
 
            AdminRestrictedResource, self).authenticate(request, rm)
224
 
        if anonymous or not request.user.is_superuser:
225
 
            raise PermissionDenied("User is not allowed access to this API.")
226
 
        else:
227
 
            return actor, anonymous
228
 
 
229
 
 
230
 
def operation(idempotent, exported_as=None):
231
 
    """Decorator to make a method available on the API.
232
 
 
233
 
    :param idempotent: If this operation is idempotent. Idempotent operations
234
 
        are made available via HTTP GET, non-idempotent operations via HTTP
235
 
        POST.
236
 
    :param exported_as: Optional operation name; defaults to the name of the
237
 
        exported method.
238
 
    """
239
 
    method = "GET" if idempotent else "POST"
240
 
 
241
 
    def _decorator(func):
242
 
        if exported_as is None:
243
 
            func.export = method, func.__name__
244
 
        else:
245
 
            func.export = method, exported_as
246
 
        return func
247
 
 
248
 
    return _decorator
249
 
 
250
 
 
251
 
class OperationsHandlerType(HandlerMetaClass):
252
 
    """Type for handlers that dispatch operations.
253
 
 
254
 
    Collects all the exported operations, CRUD and custom, into the class's
255
 
    `exports` attribute. This is a signature:function mapping, where signature
256
 
    is an (http-method, operation-name) tuple. If operation-name is None, it's
257
 
    a CRUD method.
258
 
 
259
 
    The `allowed_methods` attribute is calculated as the union of all HTTP
260
 
    methods required for the exported CRUD and custom operations.
261
 
    """
262
 
 
263
 
    def __new__(metaclass, name, bases, namespace):
264
 
        cls = super(OperationsHandlerType, metaclass).__new__(
265
 
            metaclass, name, bases, namespace)
266
 
 
267
 
        # Create a signature:function mapping for CRUD operations.
268
 
        crud = {
269
 
            (http_method, None): getattr(cls, method)
270
 
            for http_method, method in OperationsResource.crudmap.items()
271
 
            if getattr(cls, method, None) is not None
272
 
            }
273
 
 
274
 
        # Create a signature:function mapping for non-CRUD operations.
275
 
        operations = {
276
 
            attribute.export: attribute
277
 
            for attribute in vars(cls).values()
278
 
            if getattr(attribute, "export", None) is not None
279
 
            }
280
 
 
281
 
        # Create the exports mapping.
282
 
        exports = {}
283
 
        exports.update(crud)
284
 
        exports.update(operations)
285
 
 
286
 
        # Update the class.
287
 
        cls.exports = exports
288
 
        cls.allowed_methods = frozenset(
289
 
            http_method for http_method, name in exports)
290
 
 
291
 
        return cls
292
 
 
293
 
 
294
 
class OperationsHandlerMixin:
295
 
    """Handler mixin for operations dispatch.
296
 
 
297
 
    This enabled dispatch to custom functions that piggyback on HTTP methods
298
 
    that ordinarily, in Piston, are used for CRUD operations.
299
 
 
300
 
    This must be used in cooperation with :class:`OperationsResource` and
301
 
    :class:`OperationsHandlerType`.
302
 
    """
303
 
 
304
 
    def dispatch(self, request, *args, **kwargs):
305
 
        signature = request.method.upper(), request.REQUEST.get("op")
306
 
        function = self.exports.get(signature)
307
 
        if function is None:
308
 
            return HttpResponseBadRequest(
309
 
                "Unrecognised signature: %s %s" % signature)
310
 
        else:
311
 
            return function(self, request, *args, **kwargs)
312
 
 
313
 
 
314
 
class OperationsHandler(
315
 
    OperationsHandlerMixin, BaseHandler):
316
 
    """Base handler that supports operation dispatch."""
317
 
 
318
 
    __metaclass__ = OperationsHandlerType
319
 
 
320
 
 
321
 
class AnonymousOperationsHandler(
322
 
    OperationsHandlerMixin, AnonymousBaseHandler):
323
 
    """Anonymous base handler that supports operation dispatch."""
324
 
 
325
 
    __metaclass__ = OperationsHandlerType
326
 
 
327
 
 
328
 
def get_mandatory_param(data, key, validator=None):
329
 
    """Get the parameter from the provided data dict or raise a ValidationError
330
 
    if this parameter is not present.
331
 
 
332
 
    :param data: The data dict (usually request.data or request.GET where
333
 
        request is a django.http.HttpRequest).
334
 
    :param data: dict
335
 
    :param key: The parameter's key.
336
 
    :type key: basestring
337
 
    :param validator: An optional validator that will be used to validate the
338
 
         retrieved value.
339
 
    :type validator: formencode.validators.Validator
340
 
    :return: The value of the parameter.
341
 
    :raises: ValidationError
342
 
    """
343
 
    value = data.get(key, None)
344
 
    if value is None:
345
 
        raise ValidationError("No provided %s!" % key)
346
 
    if validator is not None:
347
 
        try:
348
 
            return validator.to_python(value)
349
 
        except Invalid, e:
350
 
            raise ValidationError("Invalid %s: %s" % (key, e.msg))
351
 
    else:
352
 
        return value
353
 
 
354
 
 
355
 
def get_optional_list(data, key, default=None):
356
 
    """Get the list from the provided data dict or return a default value.
357
 
    """
358
 
    value = data.getlist(key)
359
 
    if value == []:
360
 
        return default
361
 
    else:
362
 
        return value
363
 
 
364
 
 
365
 
def get_list_from_dict_or_multidict(data, key, default=None):
366
 
    """Get a list from 'data'.
367
 
 
368
 
    If data is a MultiDict, then we use 'getlist' if the data is a plain dict,
369
 
    then we just use __getitem__.
370
 
 
371
 
    The rationale is that data POSTed as multipart/form-data gets parsed into a
372
 
    MultiDict, but data POSTed as application/json gets parsed into a plain
373
 
    dict(key:list).
374
 
    """
375
 
    getlist = getattr(data, 'getlist', None)
376
 
    if getlist is not None:
377
 
        return get_optional_list(data, key, default)
378
 
    return data.get(key, default)
379
 
 
380
 
 
381
 
def extract_oauth_key_from_auth_header(auth_data):
382
 
    """Extract the oauth key from auth data in HTTP header.
383
 
 
384
 
    :param auth_data: {string} The HTTP Authorization header.
385
 
 
386
 
    :return: The oauth key from the header, or None.
387
 
    """
388
 
    for entry in auth_data.split():
389
 
        key_value = entry.split('=', 1)
390
 
        if len(key_value) == 2:
391
 
            key, value = key_value
392
 
            if key == 'oauth_token':
393
 
                return value.rstrip(',').strip('"')
394
 
    return None
395
 
 
396
 
 
397
 
def extract_oauth_key(request):
398
 
    """Extract the oauth key from a request's headers.
399
 
 
400
 
    Raises :class:`Unauthorized` if no key is found.
401
 
    """
402
 
    auth_header = request.META.get('HTTP_AUTHORIZATION')
403
 
    if auth_header is None:
404
 
        raise Unauthorized("No authorization header received.")
405
 
    key = extract_oauth_key_from_auth_header(auth_header)
406
 
    if key is None:
407
 
        raise Unauthorized("Did not find request's oauth token.")
408
 
    return key
409
 
 
410
 
 
411
 
def get_oauth_token(request):
412
 
    """Get the OAuth :class:`piston.models.Token` used for `request`.
413
 
 
414
 
    Raises :class:`Unauthorized` if no key is found, or if the token is
415
 
    unknown.
416
 
    """
417
 
    try:
418
 
        return Token.objects.get(key=extract_oauth_key(request))
419
 
    except Token.DoesNotExist:
420
 
        raise Unauthorized("Unknown OAuth token.")
421
 
 
422
 
 
423
 
def get_overrided_query_dict(defaults, data):
424
 
    """Returns a QueryDict with the values of 'defaults' overridden by the
425
 
    values in 'data'.
426
 
 
427
 
    :param defaults: The dictionary containing the default values.
428
 
    :type defaults: dict
429
 
    :param data: The data used to override the defaults.
430
 
    :type data: :class:`django.http.QueryDict`
431
 
    :return: The updated QueryDict.
432
 
    :raises: :class:`django.http.QueryDict`
433
 
    """
434
 
    # Create a writable query dict.
435
 
    new_data = QueryDict('').copy()
436
 
    # Missing fields will be taken from the node's current values.  This
437
 
    # is to circumvent Django's ModelForm (form created from a model)
438
 
    # default behaviour that requires all the fields to be defined.
439
 
    new_data.update(defaults)
440
 
    # We can't use update here because data is a QueryDict and 'update'
441
 
    # does not replaces the old values with the new as one would expect.
442
 
    for k, v in data.items():
443
 
        new_data[k] = v
444
 
    return new_data
445
 
 
446
 
 
447
206
# Node's fields exposed on the API.
448
207
DISPLAYED_NODE_FIELDS = (
449
208
    'system_id',
895
654
            raise NodeStateViolation(
896
655
                "Node(s) cannot be released in their current state: %s."
897
656
                % ', '.join(failed))
898
 
        
899
657
        return released_ids
900
 
        
 
658
 
901
659
    @operation(idempotent=True)
902
660
    def list(self, request):
903
661
        """List Nodes visible to the user, optionally filtered by criteria.
1386
1144
            raise PermissionDenied("That method is reserved to admin users.")
1387
1145
 
1388
1146
    @operation(idempotent=False)
 
1147
    def import_boot_images(self, request):
 
1148
        """Import the boot images on all the accepted cluster controllers."""
 
1149
        if not request.user.is_superuser:
 
1150
            raise PermissionDenied("That method is reserved to admin users.")
 
1151
        NodeGroup.objects.import_boot_images_accepted_clusters()
 
1152
        return HttpResponse(
 
1153
            "Import of boot images started on all cluster controllers",
 
1154
            status=httplib.OK)
 
1155
 
 
1156
    @operation(idempotent=False)
1389
1157
    def reject(self, request):
1390
1158
        """Reject nodegroup enlistment(s).
1391
1159
 
1470
1238
                {ip: leases[ip] for ip in new_leases if ip in leases})
1471
1239
        return HttpResponse("Leases updated.", status=httplib.OK)
1472
1240
 
 
1241
    @operation(idempotent=False)
 
1242
    def import_boot_images(self, request, uuid):
 
1243
        """Import the pxe files on this cluster controller."""
 
1244
        if not request.user.is_superuser:
 
1245
            raise PermissionDenied("That method is reserved to admin users.")
 
1246
        nodegroup = get_object_or_404(NodeGroup, uuid=uuid)
 
1247
        nodegroup.import_boot_images()
 
1248
        return HttpResponse(
 
1249
            "Import of boot images started on cluster %r" % nodegroup.uuid,
 
1250
            status=httplib.OK)
 
1251
 
1473
1252
    @operation(idempotent=True)
1474
1253
    def list_nodes(self, request, uuid):
1475
1254
        """Get the list of node ids that are part of this group."""
1691
1470
        'name',
1692
1471
        'definition',
1693
1472
        'comment',
 
1473
        'kernel_opts',
1694
1474
        )
1695
1475
 
1696
1476
    def read(self, request, name):
1786
1566
        definition = request.data.get('definition', None)
1787
1567
        if definition is not None and tag.definition != definition:
1788
1568
            return HttpResponse(
1789
 
                "Definition supplied '%s' doesn't match current definition '%s'"
 
1569
                "Definition supplied '%s' "
 
1570
                "doesn't match current definition '%s'"
1790
1571
                % (definition, tag.definition),
1791
1572
                status=httplib.CONFLICT)
1792
1573
        nodes_to_add = self._get_nodes_for(request, 'add', nodegroup)
1821
1602
            It is meant as a human readable description of the tag.
1822
1603
        :param definition: An XPATH query that will be evaluated against the
1823
1604
            hardware_details stored for all nodes (output of `lshw -xml`).
 
1605
        :param kernel_opts: Can be None. If set, nodes associated with this tag
 
1606
            will add this string to their kernel options when booting. The
 
1607
            value overrides the global 'kernel_opts' setting. If more than one
 
1608
            tag is associated with a node, the one with the lowest alphabetical
 
1609
            name will be picked (eg 01-my-tag will be taken over 99-tag-name).
1824
1610
        """
1825
1611
        if not request.user.is_superuser:
1826
1612
            raise PermissionDenied()
2079
1865
    else:
2080
1866
        series = node.get_distro_series()
2081
1867
 
 
1868
    if node is not None:
 
1869
        # We don't care if the kernel opts is from the global setting or a tag,
 
1870
        # just get the options
 
1871
        _, extra_kernel_opts = node.get_effective_kernel_options()
 
1872
    else:
 
1873
        extra_kernel_opts = None
 
1874
 
2082
1875
    purpose = get_boot_purpose(node)
2083
1876
    server_address = get_maas_facing_server_address(nodegroup=nodegroup)
2084
1877
    cluster_address = get_mandatory_param(request.GET, "local")
2086
1879
    params = KernelParameters(
2087
1880
        arch=arch, subarch=subarch, release=series, purpose=purpose,
2088
1881
        hostname=hostname, domain=domain, preseed_url=preseed_url,
2089
 
        log_host=server_address, fs_host=cluster_address)
 
1882
        log_host=server_address, fs_host=cluster_address,
 
1883
        extra_opts=extra_kernel_opts)
2090
1884
 
2091
1885
    return HttpResponse(
2092
1886
        json.dumps(params._asdict()),
2130
1924
            id__in=nodegroup_ids_with_images).filter(
2131
1925
                status=NODEGROUP_STATUS.ACCEPTED)
2132
1926
        if nodegroups_missing_images.exists():
 
1927
            accepted_clusters_url = (
 
1928
                "%s#accepted-clusters" % absolute_reverse("settings"))
2133
1929
            warning = dedent("""\
2134
1930
                Some cluster controllers are missing boot images.  Either the
2135
 
                maas-import-pxe-files script has not run yet, or it failed.
2136
 
 
2137
 
                Try running it manually on the affected
2138
 
                <a href="%s#accepted-clusters">cluster controllers.</a>
2139
 
                If it succeeds, this message will go away within 5 minutes.
2140
 
                """ % absolute_reverse("settings"))
 
1931
                import task has not been initiated (for each cluster, the task
 
1932
                must be <a href=%s>initiated by hand</a> the first time), or
 
1933
                the import task failed.
 
1934
                """ % quoteattr(accepted_clusters_url))
2141
1935
            register_persistent_error(COMPONENT.IMPORT_PXE_FILES, warning)
2142
1936
        else:
2143
1937
            discard_persistent_error(COMPONENT.IMPORT_PXE_FILES)
2145
1939
        return HttpResponse("OK")
2146
1940
 
2147
1941
 
 
1942
def get_content_parameter(request):
 
1943
    """Get the "content" parameter from a CommissioningScript POST or PUT."""
 
1944
    content_file = get_mandatory_param(request.FILES, 'content')
 
1945
    return content_file.read()
 
1946
 
 
1947
 
 
1948
class CommissioningScriptsHandler(OperationsHandler):
 
1949
    """Manage custom commissioning scripts.
 
1950
 
 
1951
    This functionality is only available to administrators.
 
1952
    """
 
1953
 
 
1954
    update = delete = None
 
1955
 
 
1956
    def read(self, request):
 
1957
        """List commissioning scripts."""
 
1958
        return [
 
1959
            script.name
 
1960
            for script in CommissioningScript.objects.all().order_by('name')]
 
1961
 
 
1962
    def create(self, request):
 
1963
        """Create a new commissioning script.
 
1964
 
 
1965
        Each commissioning script is identified by a unique name.
 
1966
 
 
1967
        By convention the name should consist of a two-digit number, a dash,
 
1968
        and a brief descriptive identifier consisting only of ASCII
 
1969
        characters.  You don't need to follow this convention, but not doing
 
1970
        so opens you up to risks w.r.t. encoding and ordering.  The name must
 
1971
        not contain any whitespace, quotes, or apostrophes.
 
1972
 
 
1973
        A commissioning node will run each of the scripts in lexicographical
 
1974
        order.  There are no promises about how non-ASCII characters are
 
1975
        sorted, or even how upper-case letters are sorted relative to
 
1976
        lower-case letters.  So where ordering matters, use unique numbers.
 
1977
 
 
1978
        Scripts built into MAAS will have names starting with "00-maas" or
 
1979
        "99-maas" to ensure that they run first or last, respectively.
 
1980
 
 
1981
        Usually a commissioning script will be just that, a script.  Ideally a
 
1982
        script should be ASCII text to avoid any confusion over encoding.  But
 
1983
        in some cases a commissioning script might consist of a binary tool
 
1984
        provided by a hardware vendor.  Either way, the script gets passed to
 
1985
        the commissioning node in the exact form in which it was uploaded.
 
1986
 
 
1987
        :param name: Unique identifying name for the script.  Names should
 
1988
            follow the pattern of "25-burn-in-hard-disk" (all ASCII, and with
 
1989
            numbers greater than zero, and generally no "weird" characters).
 
1990
        :param content: A script file, to be uploaded in binary form.  Note:
 
1991
            this is not a normal parameter, but a file upload.  Its filename
 
1992
            is ignored; MAAS will know it by the name you pass to the request.
 
1993
        """
 
1994
        name = get_mandatory_param(request.data, 'name')
 
1995
        content = Bin(get_content_parameter(request))
 
1996
        return CommissioningScript.objects.create(name=name, content=content)
 
1997
 
 
1998
    @classmethod
 
1999
    def resource_uri(cls):
 
2000
        return ('commissioning_scripts_handler', [])
 
2001
 
 
2002
 
 
2003
class CommissioningScriptHandler(OperationsHandler):
 
2004
    """Manage a custom commissioning script.
 
2005
 
 
2006
    This functionality is only available to administrators.
 
2007
    """
 
2008
 
 
2009
    model = CommissioningScript
 
2010
    fields = ('name', 'content')
 
2011
 
 
2012
    # Relies on Piston's built-in DELETE implementation.  There is no POST.
 
2013
    create = None
 
2014
 
 
2015
    def read(self, request, name):
 
2016
        """Read a commissioning script."""
 
2017
        script = get_object_or_404(CommissioningScript, name=name)
 
2018
        return HttpResponse(script.content, content_type='application/binary')
 
2019
 
 
2020
    def delete(self, request, name):
 
2021
        """Delete a commissioning script."""
 
2022
        script = get_object_or_404(CommissioningScript, name=name)
 
2023
        script.delete()
 
2024
        return rc.DELETED
 
2025
 
 
2026
    def update(self, request, name):
 
2027
        """Update a commissioning script."""
 
2028
        content = Bin(get_content_parameter(request))
 
2029
        script = get_object_or_404(CommissioningScript, name=name)
 
2030
        script.content = content
 
2031
        script.save()
 
2032
 
 
2033
    @classmethod
 
2034
    def resource_uri(cls, script=None):
 
2035
        # See the comment in NodeHandler.resource_uri
 
2036
        script_name = 'name'
 
2037
        if script is not None:
 
2038
            script_name = script.name
 
2039
        return ('commissioning_script_handler', (script_name, ))
 
2040
 
 
2041
 
 
2042
class CommissioningResultsHandler(OperationsHandler):
 
2043
    """Read the collection of NodeCommissionResult in the MAAS."""
 
2044
    create = read = update = delete = None
 
2045
 
 
2046
    model = NodeCommissionResult
 
2047
    fields = ('name', 'script_result', 'updated', 'created', 'node', 'data')
 
2048
 
 
2049
    @operation(idempotent=True)
 
2050
    def list(self, request):
 
2051
        """List NodeCommissionResult visible to the user, optionally filtered.
 
2052
 
 
2053
        :param system_id: An optional list of system ids.  Only the
 
2054
            commissioning results related to the nodes with these system ids
 
2055
            will be returned.
 
2056
        :type system_id: iterable
 
2057
        :param name: An optional list of names.  Only the commissioning
 
2058
            results with the specified names will be returned.
 
2059
        :type name: iterable
 
2060
        """
 
2061
        # Get filters from request.
 
2062
        system_ids = get_optional_list(request.GET, 'system_id')
 
2063
        names = get_optional_list(request.GET, 'name')
 
2064
        nodes = Node.objects.get_nodes(
 
2065
            request.user, NODE_PERMISSION.VIEW, ids=system_ids)
 
2066
        results = NodeCommissionResult.objects.filter(node_id__in=nodes)
 
2067
        if names is not None:
 
2068
            results = results.filter(name__in=names)
 
2069
        return results
 
2070
 
 
2071
    @classmethod
 
2072
    def resource_uri(cls, result=None):
 
2073
        return ('commissioning_results_handler', [])
 
2074
 
 
2075
 
2148
2076
def describe(request):
2149
2077
    """Return a description of the whole MAAS API.
2150
2078