source: trunk/web/main.py @ 259

Last change on this file since 259 was 254, checked in by andersk, 17 years ago

Remove backdoor.

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