1# Copyright (c) 2010 Mitch Garnaat http://garnaat.org/
2# Copyright (c) 2010, Eucalyptus Systems, Inc.
3#
4# Permission is hereby granted, free of charge, to any person obtaining a
5# copy of this software and associated documentation files (the
6# "Software"), to deal in the Software without restriction, including
7# without limitation the rights to use, copy, modify, merge, publish, dis-
8# tribute, sublicense, and/or sell copies of the Software, and to permit
9# persons to whom the Software is furnished to do so, subject to the fol-
10# lowing conditions:
11#
12# The above copyright notice and this permission notice shall be included
13# in all copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
17# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
18# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21# IN THE SOFTWARE.
22import sys
23import os
24import boto
25import optparse
26import copy
27import boto.exception
28import boto.roboto.awsqueryservice
29
30import bdb
31import traceback
32try:
33    import epdb as debugger
34except ImportError:
35    import pdb as debugger
36
37def boto_except_hook(debugger_flag, debug_flag):
38    def excepthook(typ, value, tb):
39        if typ is bdb.BdbQuit:
40            sys.exit(1)
41        sys.excepthook = sys.__excepthook__
42
43        if debugger_flag and sys.stdout.isatty() and sys.stdin.isatty():
44            if debugger.__name__ == 'epdb':
45                debugger.post_mortem(tb, typ, value)
46            else:
47                debugger.post_mortem(tb)
48        elif debug_flag:
49            print(traceback.print_tb(tb))
50            sys.exit(1)
51        else:
52            print(value)
53            sys.exit(1)
54
55    return excepthook
56
57class Line(object):
58
59    def __init__(self, fmt, data, label):
60        self.fmt = fmt
61        self.data = data
62        self.label = label
63        self.line = '%s\t' % label
64        self.printed = False
65
66    def append(self, datum):
67        self.line += '%s\t' % datum
68
69    def print_it(self):
70        if not self.printed:
71            print(self.line)
72            self.printed = True
73
74class RequiredParamError(boto.exception.BotoClientError):
75
76    def __init__(self, required):
77        self.required = required
78        s = 'Required parameters are missing: %s' % self.required
79        super(RequiredParamError, self).__init__(s)
80
81class EncoderError(boto.exception.BotoClientError):
82
83    def __init__(self, error_msg):
84        s = 'Error encoding value (%s)' % error_msg
85        super(EncoderError, self).__init__(s)
86
87class FilterError(boto.exception.BotoClientError):
88
89    def __init__(self, filters):
90        self.filters = filters
91        s = 'Unknown filters: %s' % self.filters
92        super(FilterError, self).__init__(s)
93
94class Encoder(object):
95
96    @classmethod
97    def encode(cls, p, rp, v, label=None):
98        if p.name.startswith('_'):
99            return
100        try:
101            mthd = getattr(cls, 'encode_'+p.ptype)
102            mthd(p, rp, v, label)
103        except AttributeError:
104            raise EncoderError('Unknown type: %s' % p.ptype)
105
106    @classmethod
107    def encode_string(cls, p, rp, v, l):
108        if l:
109            label = l
110        else:
111            label = p.name
112        rp[label] = v
113
114    encode_file = encode_string
115    encode_enum = encode_string
116
117    @classmethod
118    def encode_integer(cls, p, rp, v, l):
119        if l:
120            label = l
121        else:
122            label = p.name
123        rp[label] = '%d' % v
124
125    @classmethod
126    def encode_boolean(cls, p, rp, v, l):
127        if l:
128            label = l
129        else:
130            label = p.name
131        if v:
132            v = 'true'
133        else:
134            v = 'false'
135        rp[label] = v
136
137    @classmethod
138    def encode_datetime(cls, p, rp, v, l):
139        if l:
140            label = l
141        else:
142            label = p.name
143        rp[label] = v
144
145    @classmethod
146    def encode_array(cls, p, rp, v, l):
147        v = boto.utils.mklist(v)
148        if l:
149            label = l
150        else:
151            label = p.name
152        label = label + '.%d'
153        for i, value in enumerate(v):
154            rp[label%(i+1)] = value
155
156class AWSQueryRequest(object):
157
158    ServiceClass = None
159
160    Description = ''
161    Params = []
162    Args = []
163    Filters = []
164    Response = {}
165
166    CLITypeMap = {'string' : 'string',
167                  'integer' : 'int',
168                  'int' : 'int',
169                  'enum' : 'choice',
170                  'datetime' : 'string',
171                  'dateTime' : 'string',
172                  'file' : 'string',
173                  'boolean' : None}
174
175    @classmethod
176    def name(cls):
177        return cls.__name__
178
179    def __init__(self, **args):
180        self.args = args
181        self.parser = None
182        self.cli_options = None
183        self.cli_args = None
184        self.cli_output_format = None
185        self.connection = None
186        self.list_markers = []
187        self.item_markers = []
188        self.request_params = {}
189        self.connection_args = None
190
191    def __repr__(self):
192        return self.name()
193
194    def get_connection(self, **args):
195        if self.connection is None:
196            self.connection = self.ServiceClass(**args)
197        return self.connection
198
199    @property
200    def status(self):
201        retval = None
202        if self.http_response is not None:
203            retval = self.http_response.status
204        return retval
205
206    @property
207    def reason(self):
208        retval = None
209        if self.http_response is not None:
210            retval = self.http_response.reason
211        return retval
212
213    @property
214    def request_id(self):
215        retval = None
216        if self.aws_response is not None:
217            retval = getattr(self.aws_response, 'requestId')
218        return retval
219
220    def process_filters(self):
221        filters = self.args.get('filters', [])
222        filter_names = [f['name'] for f in self.Filters]
223        unknown_filters = [f for f in filters if f not in filter_names]
224        if unknown_filters:
225            raise FilterError('Unknown filters: %s' % unknown_filters)
226        for i, filter in enumerate(self.Filters):
227            name = filter['name']
228            if name in filters:
229                self.request_params['Filter.%d.Name' % (i+1)] = name
230                for j, value in enumerate(boto.utils.mklist(filters[name])):
231                    Encoder.encode(filter, self.request_params, value,
232                                   'Filter.%d.Value.%d' % (i+1, j+1))
233
234    def process_args(self, **args):
235        """
236        Responsible for walking through Params defined for the request and:
237
238        * Matching them with keyword parameters passed to the request
239          constructor or via the command line.
240        * Checking to see if all required parameters have been specified
241          and raising an exception, if not.
242        * Encoding each value into the set of request parameters that will
243          be sent in the request to the AWS service.
244        """
245        self.args.update(args)
246        self.connection_args = copy.copy(self.args)
247        if 'debug' in self.args and self.args['debug'] >= 2:
248            boto.set_stream_logger(self.name())
249        required = [p.name for p in self.Params+self.Args if not p.optional]
250        for param in self.Params+self.Args:
251            if param.long_name:
252                python_name = param.long_name.replace('-', '_')
253            else:
254                python_name = boto.utils.pythonize_name(param.name, '_')
255            value = None
256            if python_name in self.args:
257                value = self.args[python_name]
258            if value is None:
259                value = param.default
260            if value is not None:
261                if param.name in required:
262                    required.remove(param.name)
263                if param.request_param:
264                    if param.encoder:
265                        param.encoder(param, self.request_params, value)
266                    else:
267                        Encoder.encode(param, self.request_params, value)
268            if python_name in self.args:
269                del self.connection_args[python_name]
270        if required:
271            l = []
272            for p in self.Params+self.Args:
273                if p.name in required:
274                    if p.short_name and p.long_name:
275                        l.append('(%s, %s)' % (p.optparse_short_name,
276                                               p.optparse_long_name))
277                    elif p.short_name:
278                        l.append('(%s)' % p.optparse_short_name)
279                    else:
280                        l.append('(%s)' % p.optparse_long_name)
281            raise RequiredParamError(','.join(l))
282        boto.log.debug('request_params: %s' % self.request_params)
283        self.process_markers(self.Response)
284
285    def process_markers(self, fmt, prev_name=None):
286        if fmt and fmt['type'] == 'object':
287            for prop in fmt['properties']:
288                self.process_markers(prop, fmt['name'])
289        elif fmt and fmt['type'] == 'array':
290            self.list_markers.append(prev_name)
291            self.item_markers.append(fmt['name'])
292
293    def send(self, verb='GET', **args):
294        self.process_args(**args)
295        self.process_filters()
296        conn = self.get_connection(**self.connection_args)
297        self.http_response = conn.make_request(self.name(),
298                                               self.request_params,
299                                               verb=verb)
300        self.body = self.http_response.read()
301        boto.log.debug(self.body)
302        if self.http_response.status == 200:
303            self.aws_response = boto.jsonresponse.Element(list_marker=self.list_markers,
304                                                          item_marker=self.item_markers)
305            h = boto.jsonresponse.XmlHandler(self.aws_response, self)
306            h.parse(self.body)
307            return self.aws_response
308        else:
309            boto.log.error('%s %s' % (self.http_response.status,
310                                      self.http_response.reason))
311            boto.log.error('%s' % self.body)
312            raise conn.ResponseError(self.http_response.status,
313                                     self.http_response.reason,
314                                     self.body)
315
316    def add_standard_options(self):
317        group = optparse.OptionGroup(self.parser, 'Standard Options')
318        # add standard options that all commands get
319        group.add_option('-D', '--debug', action='store_true',
320                         help='Turn on all debugging output')
321        group.add_option('--debugger', action='store_true',
322                         default=False,
323                         help='Enable interactive debugger on error')
324        group.add_option('-U', '--url', action='store',
325                         help='Override service URL with value provided')
326        group.add_option('--region', action='store',
327                         help='Name of the region to connect to')
328        group.add_option('-I', '--access-key-id', action='store',
329                         help='Override access key value')
330        group.add_option('-S', '--secret-key', action='store',
331                         help='Override secret key value')
332        group.add_option('--version', action='store_true',
333                         help='Display version string')
334        if self.Filters:
335            self.group.add_option('--help-filters', action='store_true',
336                                   help='Display list of available filters')
337            self.group.add_option('--filter', action='append',
338                                   metavar=' name=value',
339                                   help='A filter for limiting the results')
340        self.parser.add_option_group(group)
341
342    def process_standard_options(self, options, args, d):
343        if hasattr(options, 'help_filters') and options.help_filters:
344            print('Available filters:')
345            for filter in self.Filters:
346                print('%s\t%s' % (filter.name, filter.doc))
347            sys.exit(0)
348        if options.debug:
349            self.args['debug'] = 2
350        if options.url:
351            self.args['url'] = options.url
352        if options.region:
353            self.args['region'] = options.region
354        if options.access_key_id:
355            self.args['aws_access_key_id'] = options.access_key_id
356        if options.secret_key:
357            self.args['aws_secret_access_key'] = options.secret_key
358        if options.version:
359            # TODO - Where should the version # come from?
360            print('version x.xx')
361            exit(0)
362        sys.excepthook = boto_except_hook(options.debugger,
363                                          options.debug)
364
365    def get_usage(self):
366        s = 'usage: %prog [options] '
367        l = [ a.long_name for a in self.Args ]
368        s += ' '.join(l)
369        for a in self.Args:
370            if a.doc:
371                s += '\n\n\t%s - %s' % (a.long_name, a.doc)
372        return s
373
374    def build_cli_parser(self):
375        self.parser = optparse.OptionParser(description=self.Description,
376                                            usage=self.get_usage())
377        self.add_standard_options()
378        for param in self.Params:
379            ptype = action = choices = None
380            if param.ptype in self.CLITypeMap:
381                ptype = self.CLITypeMap[param.ptype]
382                action = 'store'
383            if param.ptype == 'boolean':
384                action = 'store_true'
385            elif param.ptype == 'array':
386                if len(param.items) == 1:
387                    ptype = param.items[0]['type']
388                    action = 'append'
389            elif param.cardinality != 1:
390                action = 'append'
391            if ptype or action == 'store_true':
392                if param.short_name:
393                    self.parser.add_option(param.optparse_short_name,
394                                           param.optparse_long_name,
395                                           action=action, type=ptype,
396                                           choices=param.choices,
397                                           help=param.doc)
398                elif param.long_name:
399                    self.parser.add_option(param.optparse_long_name,
400                                           action=action, type=ptype,
401                                           choices=param.choices,
402                                           help=param.doc)
403
404    def do_cli(self):
405        if not self.parser:
406            self.build_cli_parser()
407        self.cli_options, self.cli_args = self.parser.parse_args()
408        d = {}
409        self.process_standard_options(self.cli_options, self.cli_args, d)
410        for param in self.Params:
411            if param.long_name:
412                p_name = param.long_name.replace('-', '_')
413            else:
414                p_name = boto.utils.pythonize_name(param.name)
415            value = getattr(self.cli_options, p_name)
416            if param.ptype == 'file' and value:
417                if value == '-':
418                    value = sys.stdin.read()
419                else:
420                    path = os.path.expanduser(value)
421                    path = os.path.expandvars(path)
422                    if os.path.isfile(path):
423                        fp = open(path)
424                        value = fp.read()
425                        fp.close()
426                    else:
427                        self.parser.error('Unable to read file: %s' % path)
428            d[p_name] = value
429        for arg in self.Args:
430            if arg.long_name:
431                p_name = arg.long_name.replace('-', '_')
432            else:
433                p_name = boto.utils.pythonize_name(arg.name)
434            value = None
435            if arg.cardinality == 1:
436                if len(self.cli_args) >= 1:
437                    value = self.cli_args[0]
438            else:
439                value = self.cli_args
440            d[p_name] = value
441        self.args.update(d)
442        if hasattr(self.cli_options, 'filter') and self.cli_options.filter:
443            d = {}
444            for filter in self.cli_options.filter:
445                name, value = filter.split('=')
446                d[name] = value
447            if 'filters' in self.args:
448                self.args['filters'].update(d)
449            else:
450                self.args['filters'] = d
451        try:
452            response = self.main()
453            self.cli_formatter(response)
454        except RequiredParamError as e:
455            print(e)
456            sys.exit(1)
457        except self.ServiceClass.ResponseError as err:
458            print('Error(%s): %s' % (err.error_code, err.error_message))
459            sys.exit(1)
460        except boto.roboto.awsqueryservice.NoCredentialsError as err:
461            print('Unable to find credentials.')
462            sys.exit(1)
463        except Exception as e:
464            print(e)
465            sys.exit(1)
466
467    def _generic_cli_formatter(self, fmt, data, label=''):
468        if fmt['type'] == 'object':
469            for prop in fmt['properties']:
470                if 'name' in fmt:
471                    if fmt['name'] in data:
472                        data = data[fmt['name']]
473                    if fmt['name'] in self.list_markers:
474                        label = fmt['name']
475                        if label[-1] == 's':
476                            label = label[0:-1]
477                        label = label.upper()
478                self._generic_cli_formatter(prop, data, label)
479        elif fmt['type'] == 'array':
480            for item in data:
481                line = Line(fmt, item, label)
482                if isinstance(item, dict):
483                    for field_name in item:
484                        line.append(item[field_name])
485                elif isinstance(item, basestring):
486                    line.append(item)
487                line.print_it()
488
489    def cli_formatter(self, data):
490        """
491        This method is responsible for formatting the output for the
492        command line interface.  The default behavior is to call the
493        generic CLI formatter which attempts to print something
494        reasonable.  If you want specific formatting, you should
495        override this method and do your own thing.
496
497        :type data: dict
498        :param data: The data returned by AWS.
499        """
500        if data:
501            self._generic_cli_formatter(self.Response, data)
502
503
504