source: trunk/packages/sipb-xen-www/code/main.py @ 587

Last change on this file since 587 was 587, checked in by ecprice, 16 years ago

Fix for modified status headers.

  • Property svn:executable set to *
File size: 24.4 KB
Line 
1#!/usr/bin/python
2"""Main CGI script for web interface"""
3
4import base64
5import cPickle
6import cgi
7import datetime
8import hmac
9import sha
10import simplejson
11import sys
12import time
13import urllib
14from StringIO import StringIO
15
16def revertStandardError():
17    """Move stderr to stdout, and return the contents of the old stderr."""
18    errio = sys.stderr
19    if not isinstance(errio, StringIO):
20        return None
21    sys.stderr = sys.stdout
22    errio.seek(0)
23    return errio.read()
24
25def printError():
26    """Revert stderr to stdout, and print the contents of stderr"""
27    if isinstance(sys.stderr, StringIO):
28        print revertStandardError()
29
30if __name__ == '__main__':
31    import atexit
32    atexit.register(printError)
33    sys.stderr = StringIO()
34
35sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
36
37import templates
38from Cheetah.Template import Template
39import sipb_xen_database
40from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
41import validation
42import cache_acls
43from webcommon import InvalidInput, CodeError, State
44import controls
45
46class Checkpoint:
47    def __init__(self):
48        self.start_time = time.time()
49        self.checkpoints = []
50
51    def checkpoint(self, s):
52        self.checkpoints.append((s, time.time()))
53
54    def __str__(self):
55        return ('Timing info:\n%s\n' %
56                '\n'.join(['%s: %s' % (d, t - self.start_time) for
57                           (d, t) in self.checkpoints]))
58
59checkpoint = Checkpoint()
60
61def jquote(string):
62    return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
63
64def helppopup(subj):
65    """Return HTML code for a (?) link to a specified help topic"""
66    return ('<span class="helplink"><a href="help?' +
67            cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
68            +'" target="_blank" ' +
69            'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
70
71def makeErrorPre(old, addition):
72    if addition is None:
73        return
74    if old:
75        return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
76    else:
77        return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
78
79Template.sipb_xen_database = sipb_xen_database
80Template.helppopup = staticmethod(helppopup)
81Template.err = None
82
83class JsonDict:
84    """Class to store a dictionary that will be converted to JSON"""
85    def __init__(self, **kws):
86        self.data = kws
87        if 'err' in kws:
88            err = kws['err']
89            del kws['err']
90            self.addError(err)
91
92    def __str__(self):
93        return simplejson.dumps(self.data)
94
95    def addError(self, text):
96        """Add stderr text to be displayed on the website."""
97        self.data['err'] = \
98            makeErrorPre(self.data.get('err'), text)
99
100class Defaults:
101    """Class to store default values for fields."""
102    memory = 256
103    disk = 4.0
104    cdrom = ''
105    autoinstall = ''
106    name = ''
107    type = 'linux-hvm'
108
109    def __init__(self, max_memory=None, max_disk=None, **kws):
110        if max_memory is not None:
111            self.memory = min(self.memory, max_memory)
112        if max_disk is not None:
113            self.max_disk = min(self.disk, max_disk)
114        for key in kws:
115            setattr(self, key, kws[key])
116
117
118
119DEFAULT_HEADERS = {'Content-Type': 'text/html'}
120
121def error(op, username, fields, err, emsg):
122    """Print an error page when a CodeError occurs"""
123    d = dict(op=op, user=username, errorMessage=str(err),
124             stderr=emsg)
125    return templates.error(searchList=[d])
126
127def invalidInput(op, username, fields, err, emsg):
128    """Print an error page when an InvalidInput exception occurs"""
129    d = dict(op=op, user=username, err_field=err.err_field,
130             err_value=str(err.err_value), stderr=emsg,
131             errorMessage=str(err))
132    return templates.invalid(searchList=[d])
133
134def hasVnc(status):
135    """Does the machine with a given status list support VNC?"""
136    if status is None:
137        return False
138    for l in status:
139        if l[0] == 'device' and l[1][0] == 'vfb':
140            d = dict(l[1][1:])
141            return 'location' in d
142    return False
143
144def parseCreate(username, state, fields):
145    kws = dict([(kw, fields.getfirst(kw)) for kw in 'name owner memory disksize vmtype cdrom clone_from'.split()])
146    validate = validation.Validate(username, state, strict=True, **kws)
147    return dict(contact=username, name=validate.name, memory=validate.memory,
148                disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
149                cdrom=getattr(validate, 'cdrom', None),
150                clone_from=getattr(validate, 'clone_from', None))
151
152def create(username, state, fields):
153    """Handler for create requests."""
154    try:
155        parsed_fields = parseCreate(username, state, fields)
156        machine = controls.createVm(username, state, **parsed_fields)
157    except InvalidInput, err:
158        pass
159    else:
160        err = None
161    state.clear() #Changed global state
162    d = getListDict(username, state)
163    d['err'] = err
164    if err:
165        for field in fields.keys():
166            setattr(d['defaults'], field, fields.getfirst(field))
167    else:
168        d['new_machine'] = parsed_fields['name']
169    return templates.list(searchList=[d])
170
171
172def getListDict(username, state):
173    """Gets the list of local variables used by list.tmpl."""
174    checkpoint.checkpoint('Starting')
175    machines = state.machines
176    checkpoint.checkpoint('Got my machines')
177    on = {}
178    has_vnc = {}
179    xmlist = state.xmlist
180    checkpoint.checkpoint('Got uptimes')
181    can_clone = 'ice3' not in state.xmlist_raw
182    for m in machines:
183        if m not in xmlist:
184            has_vnc[m] = 'Off'
185            m.uptime = None
186        else:
187            m.uptime = xmlist[m]['uptime']
188            if xmlist[m]['console']:
189                has_vnc[m] = True
190            elif m.type.hvm:
191                has_vnc[m] = "WTF?"
192            else:
193                has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
194    max_memory = validation.maxMemory(username, state)
195    max_disk = validation.maxDisk(username)
196    checkpoint.checkpoint('Got max mem/disk')
197    defaults = Defaults(max_memory=max_memory,
198                        max_disk=max_disk,
199                        owner=username,
200                        cdrom='gutsy-i386')
201    checkpoint.checkpoint('Got defaults')
202    def sortkey(machine):
203        return (machine.owner != username, machine.owner, machine.name)
204    machines = sorted(machines, key=sortkey)
205    d = dict(user=username,
206             cant_add_vm=validation.cantAddVm(username, state),
207             max_memory=max_memory,
208             max_disk=max_disk,
209             defaults=defaults,
210             machines=machines,
211             has_vnc=has_vnc,
212             can_clone=can_clone)
213    return d
214
215def listVms(username, state, fields):
216    """Handler for list requests."""
217    checkpoint.checkpoint('Getting list dict')
218    d = getListDict(username, state)
219    checkpoint.checkpoint('Got list dict')
220    return templates.list(searchList=[d])
221
222def vnc(username, state, fields):
223    """VNC applet page.
224
225    Note that due to same-domain restrictions, the applet connects to
226    the webserver, which needs to forward those requests to the xen
227    server.  The Xen server runs another proxy that (1) authenticates
228    and (2) finds the correct port for the VM.
229
230    You might want iptables like:
231
232    -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
233      --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
234    -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
235      --dport 10003 -j SNAT --to-source 18.187.7.142
236    -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
237      --dport 10003 -j ACCEPT
238
239    Remember to enable iptables!
240    echo 1 > /proc/sys/net/ipv4/ip_forward
241    """
242    machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
243
244    TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
245
246    data = {}
247    data["user"] = username
248    data["machine"] = machine.name
249    data["expires"] = time.time()+(5*60)
250    pickled_data = cPickle.dumps(data)
251    m = hmac.new(TOKEN_KEY, digestmod=sha)
252    m.update(pickled_data)
253    token = {'data': pickled_data, 'digest': m.digest()}
254    token = cPickle.dumps(token)
255    token = base64.urlsafe_b64encode(token)
256
257    status = controls.statusInfo(machine)
258    has_vnc = hasVnc(status)
259
260    d = dict(user=username,
261             on=status,
262             has_vnc=has_vnc,
263             machine=machine,
264             hostname=state.environ.get('SERVER_NAME', 'localhost'),
265             authtoken=token)
266    return templates.vnc(searchList=[d])
267
268def getHostname(nic):
269    """Find the hostname associated with a NIC.
270
271    XXX this should be merged with the similar logic in DNS and DHCP.
272    """
273    if nic.hostname and '.' in nic.hostname:
274        return nic.hostname
275    elif nic.machine:
276        return nic.machine.name + '.xvm.mit.edu'
277    else:
278        return None
279
280
281def getNicInfo(data_dict, machine):
282    """Helper function for info, get data on nics for a machine.
283
284    Modifies data_dict to include the relevant data, and returns a list
285    of (key, name) pairs to display "name: data_dict[key]" to the user.
286    """
287    data_dict['num_nics'] = len(machine.nics)
288    nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
289                           ('nic%s_mac', 'NIC %s MAC Addr'),
290                           ('nic%s_ip', 'NIC %s IP'),
291                           ]
292    nic_fields = []
293    for i in range(len(machine.nics)):
294        nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
295        if not i:
296            data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
297        data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
298        data_dict['nic%s_ip' % i] = machine.nics[i].ip
299    if len(machine.nics) == 1:
300        nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
301    return nic_fields
302
303def getDiskInfo(data_dict, machine):
304    """Helper function for info, get data on disks for a machine.
305
306    Modifies data_dict to include the relevant data, and returns a list
307    of (key, name) pairs to display "name: data_dict[key]" to the user.
308    """
309    data_dict['num_disks'] = len(machine.disks)
310    disk_fields_template = [('%s_size', '%s size')]
311    disk_fields = []
312    for disk in machine.disks:
313        name = disk.guest_device_name
314        disk_fields.extend([(x % name, y % name) for x, y in
315                            disk_fields_template])
316        data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
317    return disk_fields
318
319def command(username, state, fields):
320    """Handler for running commands like boot and delete on a VM."""
321    back = fields.getfirst('back')
322    try:
323        d = controls.commandResult(username, state, fields)
324        if d['command'] == 'Delete VM':
325            back = 'list'
326    except InvalidInput, err:
327        if not back:
328            raise
329        print >> sys.stderr, err
330        result = err
331    else:
332        result = 'Success!'
333        if not back:
334            return templates.command(searchList=[d])
335    if back == 'list':
336        state.clear() #Changed global state
337        d = getListDict(username, state)
338        d['result'] = result
339        return templates.list(searchList=[d])
340    elif back == 'info':
341        machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
342        return ({'Status': '302',
343                 'Location': '/info?machine_id=%d' % machine.machine_id},
344                "You shouldn't see this message.")
345    else:
346        raise InvalidInput('back', back, 'Not a known back page.')
347
348def modifyDict(username, state, fields):
349    """Modify a machine as specified by CGI arguments.
350
351    Return a list of local variables for modify.tmpl.
352    """
353    olddisk = {}
354    transaction = ctx.current.create_transaction()
355    try:
356        kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name memory vmtype disksize'.split()])
357        validate = validation.Validate(username, state, **kws)
358        machine = validate.machine
359        oldname = machine.name
360
361        if hasattr(validate, 'memory'):
362            machine.memory = validate.memory
363
364        if hasattr(validate, 'vmtype'):
365            machine.type = validate.vmtype
366
367        if hasattr(validate, 'disksize'):
368            disksize = validate.disksize
369            disk = machine.disks[0]
370            if disk.size != disksize:
371                olddisk[disk.guest_device_name] = disksize
372                disk.size = disksize
373                ctx.current.save(disk)
374
375        update_acl = False
376        if hasattr(validate, 'owner') and validate.owner != machine.owner:
377            machine.owner = validate.owner
378            update_acl = True
379        if hasattr(validate, 'name'):
380            machine.name = validate.name
381        if hasattr(validate, 'admin') and validate.admin != machine.administrator:
382            machine.administrator = validate.admin
383            update_acl = True
384        if hasattr(validate, 'contact'):
385            machine.contact = validate.contact
386
387        ctx.current.save(machine)
388        if update_acl:
389            print >> sys.stderr, machine, machine.administrator
390            cache_acls.refreshMachine(machine)
391        transaction.commit()
392    except:
393        transaction.rollback()
394        raise
395    for diskname in olddisk:
396        controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
397    if hasattr(validate, 'name'):
398        controls.renameMachine(machine, oldname, validate.name)
399    return dict(user=username,
400                command="modify",
401                machine=machine)
402
403def modify(username, state, fields):
404    """Handler for modifying attributes of a machine."""
405    try:
406        modify_dict = modifyDict(username, state, fields)
407    except InvalidInput, err:
408        result = None
409        machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
410    else:
411        machine = modify_dict['machine']
412        result = 'Success!'
413        err = None
414    info_dict = infoDict(username, state, machine)
415    info_dict['err'] = err
416    if err:
417        for field in fields.keys():
418            setattr(info_dict['defaults'], field, fields.getfirst(field))
419    info_dict['result'] = result
420    return templates.info(searchList=[info_dict])
421
422
423def helpHandler(username, state, fields):
424    """Handler for help messages."""
425    simple = fields.getfirst('simple')
426    subjects = fields.getlist('subject')
427
428    help_mapping = {'ParaVM Console': """
429ParaVM machines do not support local console access over VNC.  To
430access the serial console of these machines, you can SSH with Kerberos
431to console.xvm.mit.edu, using the name of the machine as your
432username.""",
433                    'HVM/ParaVM': """
434HVM machines use the virtualization features of the processor, while
435ParaVM machines use Xen's emulation of virtualization features.  You
436want an HVM virtualized machine.""",
437                    'CPU Weight': """
438Don't ask us!  We're as mystified as you are.""",
439                    'Owner': """
440The owner field is used to determine <a
441href="help?subject=Quotas">quotas</a>.  It must be the name of a
442locker that you are an AFS administrator of.  In particular, you or an
443AFS group you are a member of must have AFS rlidwka bits on the
444locker.  You can check who administers the LOCKER locker using the
445commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
446href="help?subject=Administrator">administrator</a>.""",
447                    'Administrator': """
448The administrator field determines who can access the console and
449power on and off the machine.  This can be either a user or a moira
450group.""",
451                    'Quotas': """
452Quotas are determined on a per-locker basis.  Each locker may have a
453maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
454active machines.""",
455                    'Console': """
456<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
457setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
458your machine will run just fine, but the applet's display of the
459console will suffer artifacts.
460"""
461                    }
462
463    if not subjects:
464        subjects = sorted(help_mapping.keys())
465
466    d = dict(user=username,
467             simple=simple,
468             subjects=subjects,
469             mapping=help_mapping)
470
471    return templates.help(searchList=[d])
472
473
474def badOperation(u, s, e):
475    """Function called when accessing an unknown URI."""
476    raise CodeError("Unknown operation")
477
478def infoDict(username, state, machine):
479    """Get the variables used by info.tmpl."""
480    status = controls.statusInfo(machine)
481    checkpoint.checkpoint('Getting status info')
482    has_vnc = hasVnc(status)
483    if status is None:
484        main_status = dict(name=machine.name,
485                           memory=str(machine.memory))
486        uptime = None
487        cputime = None
488    else:
489        main_status = dict(status[1:])
490        start_time = float(main_status.get('start_time', 0))
491        uptime = datetime.timedelta(seconds=int(time.time()-start_time))
492        cpu_time_float = float(main_status.get('cpu_time', 0))
493        cputime = datetime.timedelta(seconds=int(cpu_time_float))
494    checkpoint.checkpoint('Status')
495    display_fields = """name uptime memory state cpu_weight on_reboot
496     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
497    display_fields = [('name', 'Name'),
498                      ('owner', 'Owner'),
499                      ('administrator', 'Administrator'),
500                      ('contact', 'Contact'),
501                      ('type', 'Type'),
502                      'NIC_INFO',
503                      ('uptime', 'uptime'),
504                      ('cputime', 'CPU usage'),
505                      ('memory', 'RAM'),
506                      'DISK_INFO',
507                      ('state', 'state (xen format)'),
508                      ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
509                      ('on_reboot', 'Action on VM reboot'),
510                      ('on_poweroff', 'Action on VM poweroff'),
511                      ('on_crash', 'Action on VM crash'),
512                      ('on_xend_start', 'Action on Xen start'),
513                      ('on_xend_stop', 'Action on Xen stop'),
514                      ('bootloader', 'Bootloader options'),
515                      ]
516    fields = []
517    machine_info = {}
518    machine_info['name'] = machine.name
519    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
520    machine_info['owner'] = machine.owner
521    machine_info['administrator'] = machine.administrator
522    machine_info['contact'] = machine.contact
523
524    nic_fields = getNicInfo(machine_info, machine)
525    nic_point = display_fields.index('NIC_INFO')
526    display_fields = (display_fields[:nic_point] + nic_fields +
527                      display_fields[nic_point+1:])
528
529    disk_fields = getDiskInfo(machine_info, machine)
530    disk_point = display_fields.index('DISK_INFO')
531    display_fields = (display_fields[:disk_point] + disk_fields +
532                      display_fields[disk_point+1:])
533
534    main_status['memory'] += ' MiB'
535    for field, disp in display_fields:
536        if field in ('uptime', 'cputime') and locals()[field] is not None:
537            fields.append((disp, locals()[field]))
538        elif field in machine_info:
539            fields.append((disp, machine_info[field]))
540        elif field in main_status:
541            fields.append((disp, main_status[field]))
542        else:
543            pass
544            #fields.append((disp, None))
545
546    checkpoint.checkpoint('Got fields')
547
548
549    max_mem = validation.maxMemory(machine.owner, state, machine, False)
550    checkpoint.checkpoint('Got mem')
551    max_disk = validation.maxDisk(machine.owner, machine)
552    defaults = Defaults()
553    for name in 'machine_id name administrator owner memory contact'.split():
554        setattr(defaults, name, getattr(machine, name))
555    defaults.type = machine.type.type_id
556    defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
557    checkpoint.checkpoint('Got defaults')
558    d = dict(user=username,
559             on=status is not None,
560             machine=machine,
561             defaults=defaults,
562             has_vnc=has_vnc,
563             uptime=str(uptime),
564             ram=machine.memory,
565             max_mem=max_mem,
566             max_disk=max_disk,
567             owner_help=helppopup("Owner"),
568             fields = fields)
569    return d
570
571def info(username, state, fields):
572    """Handler for info on a single VM."""
573    machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
574    d = infoDict(username, state, machine)
575    checkpoint.checkpoint('Got infodict')
576    return templates.info(searchList=[d])
577
578def unauthFront(_, _2, fields):
579    """Information for unauth'd users."""
580    return templates.unauth(searchList=[{'simple' : True}])
581
582mapping = dict(list=listVms,
583               vnc=vnc,
584               command=command,
585               modify=modify,
586               info=info,
587               create=create,
588               help=helpHandler,
589               unauth=unauthFront)
590
591def printHeaders(headers):
592    """Print a dictionary as HTTP headers."""
593    for key, value in headers.iteritems():
594        print '%s: %s' % (key, value)
595    print
596
597
598def getUser(environ):
599    """Return the current user based on the SSL environment variables"""
600    email = environ.get('SSL_CLIENT_S_DN_Email', None)
601    if email is None:
602        return None
603    if not email.endswith('@MIT.EDU'):
604        return None
605    return email[:-8]
606
607class App:
608    def __init__(self, environ, start_response):
609        self.environ = environ
610        self.start = start_response
611
612        self.username = getUser(environ)
613        self.state = State(self.username)
614        self.state.environ = environ
615
616    def __iter__(self):
617        fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
618        print >> sys.stderr, fields
619        operation = self.environ.get('PATH_INFO', '')
620        if not operation:
621            self.start("301 Moved Permanently", [('Location',
622                                                  self.environ['SCRIPT_NAME']+'/')])
623            return
624        if self.username is None:
625            operation = 'unauth'
626        if operation.startswith('/'):
627            operation = operation[1:]
628        if not operation:
629            operation = 'list'
630        print 'Starting', operation
631
632        start_time = time.time()
633        fun = mapping.get(operation, badOperation)
634        try:
635            checkpoint.checkpoint('Before')
636            output = fun(self.username, self.state, fields)
637            checkpoint.checkpoint('After')
638
639            headers = dict(DEFAULT_HEADERS)
640            if isinstance(output, tuple):
641                new_headers, output = output
642                headers.update(new_headers)
643            print 'MOO2'
644            e = revertStandardError()
645            if e:
646                if isinstance(output, basestring):
647                    sys.stderr = StringIO()
648                    x = str(output)
649                    print >> sys.stderr, x
650                    print >> sys.stderr, 'XXX'
651                    print >> sys.stderr, e
652                    raise Exception()
653                output.addError(e)
654            output_string =  str(output)
655            checkpoint.checkpoint('output as a string')
656        except Exception, err:
657            if not fields.has_key('js'):
658                if isinstance(err, CodeError):
659                    self.start('500 Internal Server Error', [('Content-Type', 'text/html')])
660                    e = revertStandardError()
661                    s = error(operation, self.username, fields, err, e)
662                    yield str(s)
663                    return
664                if isinstance(err, InvalidInput):
665                    self.start('200 OK', [('Content-Type', 'text/html')])
666                    e = revertStandardError()
667                    yield str(invalidInput(operation, self.username, fields, err, e))
668                    return
669            self.start('500 Internal Server Error', [('Content-Type', 'text/plain')])
670            import traceback
671            yield '''Uh-oh!  We experienced an error.'
672Please email xvm-dev@mit.edu with the contents of this page.'
673----
674%s
675----
676%s
677----''' % (str(err), traceback.format_exc())
678        status = headers.setdefault('Status', '200 OK')
679        del headers['Status']
680        self.start(status, headers.items())
681        yield output_string
682        if fields.has_key('timedebug'):
683            yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
684
685def constructor():
686    connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
687    return App
688
689def main():
690    from flup.server.fcgi_fork import WSGIServer
691    WSGIServer(constructor()).run()
692
693if __name__ == '__main__':
694    main()
Note: See TracBrowser for help on using the repository browser.