This file is indexed.

/usr/lib/python3/dist-packages/maascli/api.py is in python3-maas-client 2.4.0~beta2-6865-gec43e47e6-0ubuntu1.

This file is owned by root:root, with mode 0o644.

The actual contents of the file can be viewed below.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# Copyright 2012-2016 Canonical Ltd.  This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

"""Interact with a remote MAAS server."""

__all__ = [
    "register_api_commands",
    ]

import argparse
from collections import defaultdict
from functools import partial
import http.client
import json
from operator import itemgetter
import re
import sys
from textwrap import (
    dedent,
    fill,
    wrap,
)
from urllib.parse import (
    urljoin,
    urlparse,
)

from apiclient.maas_client import MAASOAuth
from apiclient.multipart import (
    build_multipart_message,
    encode_multipart_message,
)
from apiclient.utils import (
    ascii_url,
    urlencode,
)
import httplib2
from maascli import utils
from maascli.command import (
    Command,
    CommandError,
)
from maascli.config import ProfileConfig
from maascli.utils import (
    handler_command_name,
    parse_docstring,
    safe_name,
    try_import_module,
)


def http_request(url, method, body=None, headers=None, insecure=False):
    """Issue an http request."""
    http = httplib2.Http(
        disable_ssl_certificate_validation=insecure)
    try:
        # XXX mpontillo 2015-12-15: Should force input to be in bytes here.
        # This calls into httplib2, which is going to call a parser which
        # expects this to be a `str`.
        if isinstance(url, bytes):
            url = url.decode('ascii')
        return http.request(url, method, body=body, headers=headers)
    except httplib2.ssl.SSLError:
        raise CommandError(
            "Certificate verification failed, use --insecure/-k to "
            "disable the certificate check.")


def fetch_api_description(url, insecure=False):
    """Obtain the description of remote API given its base URL."""
    url_describe = urljoin(url, "describe/")
    response, content = http_request(
        ascii_url(url_describe), "GET", insecure=insecure)
    if response.status != http.client.OK:
        raise CommandError(
            "{0.status} {0.reason}:\n{1}".format(response, content))
    if response["content-type"] != "application/json":
        raise CommandError(
            "Expected application/json, got: %(content-type)s" % response)
    # XXX mpontillo 2015-12-15: We don't actually know that this is UTF-8, but
    # I'm keeping it here, because if it happens to be non-ASCII, chances are
    # good that it'll be UTF-8.
    return json.loads(content.decode('utf-8'))


class Action(Command):
    """A generic MAAS API action.

    This is used as a base for creating more specific commands; see
    `register_actions`.

    **Note** that this class conflates two things: CLI exposure and API
    client. The client in apiclient.maas_client is not quite suitable yet, but
    it should be iterated upon to make it suitable.
    """

    # Override these in subclasses; see `register_actions`.
    profile = handler = action = None

    uri = property(lambda self: self.handler["uri"])
    method = property(lambda self: self.action["method"])
    credentials = property(lambda self: self.profile["credentials"])
    op = property(lambda self: self.action["op"])

    def __init__(self, parser):
        super(Action, self).__init__(parser)
        for param in self.handler["params"]:
            parser.add_argument(param)
        parser.add_argument(
            "data", type=self.name_value_pair, nargs="*")
        parser.add_argument(
            "-d", "--debug", action="store_true", default=False,
            help="Display more information about API responses.")
        parser.add_argument(
            '-k', '--insecure', action='store_true', help=(
                "Disable SSL certificate check"), default=False)

    def __call__(self, options):
        # TODO: this is el-cheapo URI Template
        # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py
        # <https://github.com/uri-templates/uritemplate-py> here?
        uri = self.uri.format(**vars(options))

        # Bundle things up ready to throw over the wire.
        uri, body, headers = self.prepare_payload(
            self.op, self.method, uri, options.data)

        # Headers are returned as a list, but they must be a dict for
        # the signing machinery.
        headers = dict(headers)

        # Sign request if credentials have been provided.
        if self.credentials is not None:
            self.sign(uri, headers, self.credentials)

        # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based
        # on urllib2) so that we get full control over HTTP method. TODO:
        # create custom MAASDispatcher to use httplib2 so that MAASClient can
        # be used.
        insecure = options.insecure
        response, content = http_request(
            uri, self.method, body=body, headers=headers,
            insecure=insecure)

        # Compare API hashes to see if our version of the API is old.
        self.compare_api_hashes(self.profile, response)

        # Output.
        if options.debug:
            utils.dump_response_summary(response)
        utils.print_response_content(response, content)

        # 2xx status codes are all okay.
        if response.status // 100 != 2:
            raise CommandError(2)

    @staticmethod
    def compare_api_hashes(profile, response):
        """Compare the local and remote API hashes.

        If they differ -- or the remote side reports a hash and there is no
        hash stored locally -- then show a warning to the user.
        """
        hash_from_response = response.get("X-MAAS-API-Hash".lower())
        if hash_from_response is not None:
            hash_from_profile = profile["description"].get("hash")
            if hash_from_profile != hash_from_response:
                warning = dedent("""\
                WARNING! The API on the server differs from the description
                that is cached locally. This may result in failed API calls.
                Refresh the local API description with `maas refresh`.
                """)
                warning_lines = wrap(
                    warning, width=70, initial_indent="*** ",
                    subsequent_indent="*** ")
                print("**********" * 7, file=sys.stderr)
                for warning_line in warning_lines:
                    print(warning_line, file=sys.stderr)
                print("**********" * 7, file=sys.stderr)

    @staticmethod
    def name_value_pair(string):
        """Ensure that `string` is a valid ``name:value`` pair.

        When `string` is of the form ``name=value``, this returns a
        2-tuple of ``name, value``.

        However, when `string` is of the form ``name@=value``, this
        returns a ``name, opener`` tuple, where ``opener`` is a function
        that will return an open file handle when called. The file will
        be opened in binary mode for reading only.
        """
        parts = re.split(r'(=|@=)', string, 1)
        if len(parts) == 3:
            name, what, value = parts
            if what == "=":
                return name, value
            elif what == "@=":
                return name, partial(open, value, "rb")
            else:
                raise AssertionError(
                    "Unrecognised separator %r" % what)
        else:
            raise CommandError(
                "%r is not a name=value or name@=filename pair" % string)

    @classmethod
    def prepare_payload(cls, op, method, uri, data):
        """Return the URI (modified perhaps) and body and headers.

        - For GET requests, encode parameters in the query string.

        - Otherwise always encode parameters in the request body.

        - Except op; this can always go in the query string.

        :param method: The HTTP method.
        :param uri: The URI of the action.
        :param data: An iterable of ``name, value`` or ``name, opener``
            tuples (see `name_value_pair`) to pack into the body or
            query, depending on the type of request.
        """
        query = [] if op is None else [("op", op)]

        def slurp(opener):
            with opener() as fd:
                return fd.read()

        if method in ["GET", "DELETE"]:
            query.extend(
                (name, slurp(value) if callable(value) else value)
                for name, value in data)
            body, headers = None, []
        else:
            if data is None or len(data) == 0:
                body, headers = None, []
            else:
                message = build_multipart_message(data)
                headers, body = encode_multipart_message(message)

        uri = urlparse(uri)._replace(query=urlencode(query)).geturl()
        return uri, body, headers

    @staticmethod
    def sign(uri, headers, credentials):
        """Sign the URI and headers."""
        auth = MAASOAuth(*credentials)
        auth.sign_request(uri, headers)


class ActionHelp(argparse.Action):
    """Custom "help" function for an action `ArgumentParser`.

    We use the argument parser's "epilog" field for the action's detailed
    description.

    This class is stateless.
    """

    keyword_args_help = dedent("""\
        This method accepts keyword arguments.  Pass each argument as a
        key-value pair with an equals sign between the key and the value:
        key1=value1 key2=value key3=value3.  Keyword arguments must come after
        any positional arguments.
        """)

    @classmethod
    def get_positional_args(cls, parser):
        """Return an API action's positional arguments.

        Most typically, this holds a URL path fragment for the object that's
        being addressed, e.g. a physical zone's name.

        There will also be a "data" argument, representing the request's
        embedded data, but that's of no interest to end-users.  The CLI offers
        a more fine-grained interface to pass parameters, so as a special case,
        that one item is left out.
        """
        # Use private method on the parser.  The list of actions does not
        # seem to be publicly exposed.
        positional_actions = parser._get_positional_actions()
        names = [action.dest for action in positional_actions]
        if len(names) > 0 and names[-1] == 'data':
            names = names[:-1]
        return names

    @classmethod
    def get_optional_args(cls, parser):
        """Return an API action's optional arguments."""
        # Use private method on the parser.  The list of actions does not
        # seem to be publicly exposed.
        optional_args = parser._get_optional_actions()
        return optional_args

    @classmethod
    def compose_positional_args(cls, parser):
        """Describe positional arguments for `parser`, as a list of strings."""
        positional_args = cls.get_positional_args(parser)
        if len(positional_args) == 0:
            return []
        else:
            return [
                '',
                '',
                "Positional arguments:",
                ] + ["\t%s" % arg for arg in positional_args]

    @classmethod
    def compose_epilog(cls, parser):
        """Describe action in detail, as a list of strings."""
        epilog = parser.epilog
        if parser.epilog is None:
            return []
        epilog = epilog.rstrip()
        if epilog == '':
            return []

        lines = [
            '',
            '',
            ]
        if ':param ' in epilog:
            # This API action documents keyword arguments.  Explain to the
            # user how those work first.
            lines.append(cls.keyword_args_help)
        # Finally, include the actual documentation body.
        lines.append(epilog)
        return lines

    @classmethod
    def compose_optional_args(cls, parser):
        """Describe optional arguments for `parser`, as a list of strings."""
        optional_args = cls.get_optional_args(parser)
        if len(optional_args) == 0:
            return []

        lines = [
            '',
            '',
            "Common command-line options:",
            ]
        for arg in optional_args:
            # Minimal representation of options.  Doesn't show
            # arguments to the options, defaults, and so on.  But it's
            # all we need for now.
            lines.append('    %s' % ', '.join(arg.option_strings))
            lines.append('\t%s' % arg.help)
        return lines

    @classmethod
    def compose(cls, parser):
        """Put together, and return, help output for `parser`."""
        lines = [
            parser.format_usage().rstrip(),
            '',
            parser.description,
            ]
        lines += cls.compose_positional_args(parser)
        lines += cls.compose_epilog(parser)
        lines += cls.compose_optional_args(parser)
        return '\n'.join(lines)

    def __call__(self, parser, namespace, values, option_string):
        """Overridable as defined by the `argparse` API."""
        print(self.compose(parser))
        sys.exit(0)


def get_action_class(handler, action):
    """Return custom action handler class."""
    handler_name = handler_command_name(handler["name"]).replace('-', '_')
    action_name = '%s_%s' % (
        handler_name,
        safe_name(action["name"]).replace('-', '_'))
    action_module = try_import_module('maascli.actions.%s' % action_name)
    if action_module is not None:
        return action_module.action_class
    return None


def get_action_class_bases(handler, action):
    """Return the base classes for the dynamic class."""
    action_class = get_action_class(handler, action)
    if action_class is not None:
        return (action_class,)
    return (Action,)


def register_actions(profile, handler, parser):
    """Register a handler's actions."""
    for action in handler["actions"]:
        help_title, help_body = parse_docstring(action["doc"])
        action_name = safe_name(action["name"])
        action_bases = get_action_class_bases(handler, action)
        action_ns = {
            "action": action,
            "handler": handler,
            "profile": profile,
            }
        action_class = type(action_name, action_bases, action_ns)
        action_parser = parser.subparsers.add_parser(
            action_name, help=help_title, description=help_title,
            epilog=help_body, add_help=False)
        action_parser.add_argument(
            '--help', '-h', action=ActionHelp, nargs=0,
            help="Show this help message and exit.")
        action_parser.set_defaults(execute=action_class(action_parser))


def register_handler(profile, handler, parser):
    """Register a resource's handler."""
    help_title, help_body = parse_docstring(handler["doc"])
    handler_name = handler_command_name(handler["name"])
    handler_parser = parser.subparsers.add_parser(
        handler_name, help=help_title, description=help_title,
        epilog=help_body)
    register_actions(profile, handler, handler_parser)


def register_resources(profile, parser):
    """Register a profile's resources."""
    anonymous = profile["credentials"] is None
    description = profile["description"]
    resources = description["resources"]
    for resource in sorted(resources, key=itemgetter("name")):
        # Don't consider the authenticated handler if this profile has no
        # credentials associated with it.
        if anonymous:
            handlers = [resource["anon"]]
        else:
            handlers = [resource["auth"], resource["anon"]]
        # Merge actions from the active handlers. This could be slightly
        # simpler using a dict and going through the handlers in reverse, but
        # doing it forwards with a defaultdict(list) leaves an easier-to-debug
        # structure, and ought to be easier to understand.
        actions = defaultdict(list)
        for handler in handlers:
            if handler is not None:
                for action in handler["actions"]:
                    action_name = action["name"]
                    actions[action_name].append(action)
        # Always represent this resource using the authenticated handler, if
        # defined, before the fall-back anonymous handler, even if this
        # profile does not have credentials.
        represent_as = dict(
            resource["auth"] or resource["anon"],
            name=resource["name"], actions=[])
        # Each value in the actions dict is a list of one or more action
        # descriptions. Here we register the handler with only the first of
        # each of those.
        if len(actions) != 0:
            represent_as["actions"].extend(
                value[0] for value in actions.values())
            register_handler(profile, represent_as, parser)

profile_help_paragraphs = [
    """\
    This is a profile.  Any commands you issue on this
    profile will operate on the MAAS region server.
    """,
    """\
    The command information you see here comes from the
    region server's API; it may differ for different
    profiles.  If you believe the API may have changed,
    use the command's 'refresh' sub-command to fetch the
    latest version of this help information from the
    server.
    """,
]
profile_help = '\n\n'.join(
    fill(dedent(paragraph)) for paragraph in profile_help_paragraphs)


def register_api_commands(parser):
    """Register all profiles as subcommands on `parser`."""
    with ProfileConfig.open() as config:
        for profile_name in config:
            profile = config[profile_name]
            profile_parser = parser.subparsers.add_parser(
                profile["name"], help="Interact with %(url)s" % profile,
                description=(
                    "Issue commands to the MAAS region controller at %(url)s."
                    % profile),
                epilog=profile_help)
            register_resources(profile, profile_parser)