source: trunk/web/main.py @ 266

Last change on this file since 266 was 266, checked in by quentin, 16 years ago

Check quotas against the machine's owner and not the user committing the act

  • 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
[261]39from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess
[209]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')
[266]149    memory = validation.validMemory(owner, memory, on=True)
[134]150   
[243]151    disk_size = fields.getfirst('disk')
[266]152    disk_size = validation.validDisk(owner, 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):
[261]186    machines = g.machines
[235]187    checkpoint.checkpoint('Got my machines')
[133]188    on = {}
[119]189    has_vnc = {}
[152]190    on = g.uptimes
[235]191    checkpoint.checkpoint('Got uptimes')
[136]192    for m in machines:
[205]193        m.uptime = g.uptimes.get(m)
[144]194        if not on[m]:
195            has_vnc[m] = 'Off'
[138]196        elif m.type.hvm:
[144]197            has_vnc[m] = True
[136]198        else:
[144]199            has_vnc[m] = "ParaVM"+helppopup("paravm_console")
[209]200    max_memory = validation.maxMemory(user)
201    max_disk = validation.maxDisk(user)
[235]202    checkpoint.checkpoint('Got max mem/disk')
[205]203    defaults = Defaults(max_memory=max_memory,
204                        max_disk=max_disk,
[228]205                        owner=user,
[205]206                        cdrom='gutsy-i386')
[235]207    checkpoint.checkpoint('Got defaults')
[113]208    d = dict(user=user,
[209]209             cant_add_vm=validation.cantAddVm(user),
[205]210             max_memory=max_memory,
[144]211             max_disk=max_disk,
[205]212             defaults=defaults,
[113]213             machines=machines,
[119]214             has_vnc=has_vnc,
[157]215             uptimes=g.uptimes,
[113]216             cdroms=CDROM.select())
[205]217    return d
[113]218
[205]219def listVms(user, fields):
220    """Handler for list requests."""
[235]221    checkpoint.checkpoint('Getting list dict')
[205]222    d = getListDict(user)
[235]223    checkpoint.checkpoint('Got list dict')
224    return templates.list(searchList=[d])
[205]225           
[113]226def vnc(user, fields):
[119]227    """VNC applet page.
228
229    Note that due to same-domain restrictions, the applet connects to
230    the webserver, which needs to forward those requests to the xen
231    server.  The Xen server runs another proxy that (1) authenticates
232    and (2) finds the correct port for the VM.
233
234    You might want iptables like:
235
[205]236    -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
237      --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
238    -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
239      --dport 10003 -j SNAT --to-source 18.187.7.142
240    -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
241      --dport 10003 -j ACCEPT
[145]242
243    Remember to enable iptables!
244    echo 1 > /proc/sys/net/ipv4/ip_forward
[119]245    """
[209]246    machine = validation.testMachineId(user, fields.getfirst('machine_id'))
[118]247   
248    TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
249
250    data = {}
[228]251    data["user"] = user
[205]252    data["machine"] = machine.name
253    data["expires"] = time.time()+(5*60)
254    pickled_data = cPickle.dumps(data)
[118]255    m = hmac.new(TOKEN_KEY, digestmod=sha)
[205]256    m.update(pickled_data)
257    token = {'data': pickled_data, 'digest': m.digest()}
[118]258    token = cPickle.dumps(token)
259    token = base64.urlsafe_b64encode(token)
260   
[209]261    status = controls.statusInfo(machine)
[152]262    has_vnc = hasVnc(status)
263   
[113]264    d = dict(user=user,
[152]265             on=status,
266             has_vnc=has_vnc,
[113]267             machine=machine,
[119]268             hostname=os.environ.get('SERVER_NAME', 'localhost'),
[113]269             authtoken=token)
[235]270    return templates.vnc(searchList=[d])
[113]271
[252]272def getHostname(nic):
273    if nic.hostname and '.' in nic.hostname:
274        return nic.hostname
275    elif nic.machine:
276        return nic.machine.name + '.servers.csail.mit.edu'
277    else:
278        return None
279
280
[133]281def getNicInfo(data_dict, machine):
[145]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    """
[133]287    data_dict['num_nics'] = len(machine.nics)
[227]288    nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
[133]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])
[227]295        if not i:
[252]296            data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
[133]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):
[145]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    """
[133]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
[205]314        disk_fields.extend([(x % name, y % name) for x, y in 
315                            disk_fields_template])
[211]316        data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
[133]317    return disk_fields
318
[205]319def command(user, fields):
320    """Handler for running commands like boot and delete on a VM."""
[207]321    back = fields.getfirst('back')
[205]322    try:
[209]323        d = controls.commandResult(user, fields)
[207]324        if d['command'] == 'Delete VM':
325            back = 'list'
[205]326    except InvalidInput, err:
[207]327        if not back:
[205]328            raise
[261]329        #print >> sys.stderr, err
330        result = err
[205]331    else:
332        result = 'Success!'
[207]333        if not back:
[235]334            return templates.command(searchList=[d])
[207]335    if back == 'list':
[205]336        g.clear() #Changed global state
337        d = getListDict(user)
[207]338        d['result'] = result
[235]339        return templates.list(searchList=[d])
[207]340    elif back == 'info':
[209]341        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
[205]342        d = infoDict(user, machine)
[207]343        d['result'] = result
[235]344        return templates.info(searchList=[d])
[205]345    else:
[261]346        raise InvalidInput('back', back, 'Not a known back page.')
[205]347
348def modifyDict(user, fields):
[177]349    olddisk = {}
[161]350    transaction = ctx.current.create_transaction()
351    try:
[209]352        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
353        owner = validation.testOwner(user, fields.getfirst('owner'), machine)
354        admin = validation.testAdmin(user, fields.getfirst('administrator'),
355                                     machine)
356        contact = validation.testContact(user, fields.getfirst('contact'),
357                                         machine)
358        name = validation.testName(user, fields.getfirst('name'), machine)
[161]359        oldname = machine.name
[205]360        command = "modify"
[153]361
[161]362        memory = fields.getfirst('memory')
363        if memory is not None:
[209]364            memory = validation.validMemory(user, memory, machine, on=False)
[161]365            machine.memory = memory
[177]366 
[209]367        disksize = validation.testDisk(user, fields.getfirst('disk'))
[161]368        if disksize is not None:
[209]369            disksize = validation.validDisk(user, disksize, machine)
[177]370            disk = machine.disks[0]
371            if disk.size != disksize:
372                olddisk[disk.guest_device_name] = disksize
373                disk.size = disksize
374                ctx.current.save(disk)
[161]375       
[187]376        if owner is not None:
[161]377            machine.owner = owner
[187]378        if name is not None:
[161]379            machine.name = name
[187]380        if admin is not None:
381            machine.administrator = admin
382        if contact is not None:
383            machine.contact = contact
[161]384           
385        ctx.current.save(machine)
386        transaction.commit()
387    except:
388        transaction.rollback()
[163]389        raise
[177]390    for diskname in olddisk:
[209]391        controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
[187]392    if name is not None:
[209]393        controls.renameMachine(machine, oldname, name)
[205]394    return dict(user=user,
395                command=command,
396                machine=machine)
397   
398def modify(user, fields):
399    """Handler for modifying attributes of a machine."""
400    try:
401        modify_dict = modifyDict(user, fields)
402    except InvalidInput, err:
[207]403        result = None
[209]404        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
[205]405    else:
406        machine = modify_dict['machine']
[209]407        result = 'Success!'
[205]408        err = None
409    info_dict = infoDict(user, machine)
410    info_dict['err'] = err
411    if err:
412        for field in fields.keys():
413            setattr(info_dict['defaults'], field, fields.getfirst(field))
[207]414    info_dict['result'] = result
[235]415    return templates.info(searchList=[info_dict])
[205]416   
[161]417
[205]418def helpHandler(user, fields):
[145]419    """Handler for help messages."""
[139]420    simple = fields.getfirst('simple')
421    subjects = fields.getlist('subject')
422   
[205]423    help_mapping = dict(paravm_console="""
[139]424ParaVM machines do not support console access over VNC.  To access
425these machines, you either need to boot with a liveCD and ssh in or
426hope that the sipb-xen maintainers add support for serial consoles.""",
[205]427                        hvm_paravm="""
[139]428HVM machines use the virtualization features of the processor, while
429ParaVM machines use Xen's emulation of virtualization features.  You
430want an HVM virtualized machine.""",
[205]431                        cpu_weight="""
432Don't ask us!  We're as mystified as you are.""",
433                        owner="""
434The owner field is used to determine <a
435href="help?subject=quotas">quotas</a>.  It must be the name of a
436locker that you are an AFS administrator of.  In particular, you or an
437AFS group you are a member of must have AFS rlidwka bits on the
[187]438locker.  You can check see who administers the LOCKER locker using the
[205]439command 'fs la /mit/LOCKER' on Athena.)  See also <a
440href="help?subject=administrator">administrator</a>.""",
441                        administrator="""
442The administrator field determines who can access the console and
443power on and off the machine.  This can be either a user or a moira
444group.""",
445                        quotas="""
446Quotas are determined on a per-locker basis.  Each quota may have a
447maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
448active machines."""
[187]449                   )
[139]450   
[187]451    if not subjects:
[205]452        subjects = sorted(help_mapping.keys())
[187]453       
[139]454    d = dict(user=user,
455             simple=simple,
456             subjects=subjects,
[205]457             mapping=help_mapping)
[139]458   
[235]459    return templates.help(searchList=[d])
[139]460   
[133]461
[205]462def badOperation(u, e):
463    raise CodeError("Unknown operation")
464
465def infoDict(user, machine):
[209]466    status = controls.statusInfo(machine)
[235]467    checkpoint.checkpoint('Getting status info')
[133]468    has_vnc = hasVnc(status)
469    if status is None:
470        main_status = dict(name=machine.name,
471                           memory=str(machine.memory))
[205]472        uptime = None
473        cputime = None
[133]474    else:
475        main_status = dict(status[1:])
[167]476        start_time = float(main_status.get('start_time', 0))
477        uptime = datetime.timedelta(seconds=int(time.time()-start_time))
478        cpu_time_float = float(main_status.get('cpu_time', 0))
479        cputime = datetime.timedelta(seconds=int(cpu_time_float))
[235]480    checkpoint.checkpoint('Status')
[133]481    display_fields = """name uptime memory state cpu_weight on_reboot
482     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
483    display_fields = [('name', 'Name'),
484                      ('owner', 'Owner'),
[187]485                      ('administrator', 'Administrator'),
[133]486                      ('contact', 'Contact'),
[136]487                      ('type', 'Type'),
[133]488                      'NIC_INFO',
489                      ('uptime', 'uptime'),
490                      ('cputime', 'CPU usage'),
491                      ('memory', 'RAM'),
492                      'DISK_INFO',
493                      ('state', 'state (xen format)'),
[139]494                      ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
[133]495                      ('on_reboot', 'Action on VM reboot'),
496                      ('on_poweroff', 'Action on VM poweroff'),
497                      ('on_crash', 'Action on VM crash'),
498                      ('on_xend_start', 'Action on Xen start'),
499                      ('on_xend_stop', 'Action on Xen stop'),
500                      ('bootloader', 'Bootloader options'),
501                      ]
502    fields = []
503    machine_info = {}
[147]504    machine_info['name'] = machine.name
[136]505    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
[133]506    machine_info['owner'] = machine.owner
[187]507    machine_info['administrator'] = machine.administrator
[133]508    machine_info['contact'] = machine.contact
509
510    nic_fields = getNicInfo(machine_info, machine)
511    nic_point = display_fields.index('NIC_INFO')
[205]512    display_fields = (display_fields[:nic_point] + nic_fields + 
513                      display_fields[nic_point+1:])
[133]514
515    disk_fields = getDiskInfo(machine_info, machine)
516    disk_point = display_fields.index('DISK_INFO')
[205]517    display_fields = (display_fields[:disk_point] + disk_fields + 
518                      display_fields[disk_point+1:])
[133]519   
[211]520    main_status['memory'] += ' MiB'
[133]521    for field, disp in display_fields:
[167]522        if field in ('uptime', 'cputime') and locals()[field] is not None:
[133]523            fields.append((disp, locals()[field]))
[147]524        elif field in machine_info:
525            fields.append((disp, machine_info[field]))
[133]526        elif field in main_status:
527            fields.append((disp, main_status[field]))
528        else:
529            pass
530            #fields.append((disp, None))
[235]531
532    checkpoint.checkpoint('Got fields')
533
534
535    max_mem = validation.maxMemory(user, machine, False)
536    checkpoint.checkpoint('Got mem')
[209]537    max_disk = validation.maxDisk(user, machine)
538    defaults = Defaults()
[205]539    for name in 'machine_id name administrator owner memory contact'.split():
540        setattr(defaults, name, getattr(machine, name))
541    defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
[235]542    checkpoint.checkpoint('Got defaults')
[113]543    d = dict(user=user,
[133]544             cdroms=CDROM.select(),
545             on=status is not None,
546             machine=machine,
[205]547             defaults=defaults,
[133]548             has_vnc=has_vnc,
549             uptime=str(uptime),
550             ram=machine.memory,
[144]551             max_mem=max_mem,
552             max_disk=max_disk,
[166]553             owner_help=helppopup("owner"),
[133]554             fields = fields)
[205]555    return d
[113]556
[205]557def info(user, fields):
558    """Handler for info on a single VM."""
[209]559    machine = validation.testMachineId(user, fields.getfirst('machine_id'))
[205]560    d = infoDict(user, machine)
[235]561    checkpoint.checkpoint('Got infodict')
562    return templates.info(searchList=[d])
[205]563
[113]564mapping = dict(list=listVms,
565               vnc=vnc,
[133]566               command=command,
567               modify=modify,
[113]568               info=info,
[139]569               create=create,
[205]570               help=helpHandler)
[113]571
[205]572def printHeaders(headers):
573    for key, value in headers.iteritems():
574        print '%s: %s' % (key, value)
575    print
576
577
578def getUser():
579    """Return the current user based on the SSL environment variables"""
[254]580    username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
581    return username
[205]582
[209]583def main(operation, user, fields):   
[235]584    start_time = time.time()
[153]585    fun = mapping.get(operation, badOperation)
[205]586
587    if fun not in (helpHandler, ):
588        connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
[119]589    try:
[235]590        checkpoint.checkpoint('Before')
[153]591        output = fun(u, fields)
[235]592        checkpoint.checkpoint('After')
[205]593
[209]594        headers = dict(DEFAULT_HEADERS)
[205]595        if isinstance(output, tuple):
596            new_headers, output = output
597            headers.update(new_headers)
598        e = revertStandardError()
[153]599        if e:
[205]600            output.addError(e)
601        printHeaders(headers)
[235]602        output_string =  str(output)
603        checkpoint.checkpoint('output as a string')
604        print output_string
605        print '<pre>%s</pre>' % checkpoint
[205]606    except Exception, err:
607        if not fields.has_key('js'):
608            if isinstance(err, CodeError):
609                print 'Content-Type: text/html\n'
610                e = revertStandardError()
611                print error(operation, u, fields, err, e)
612                sys.exit(1)
613            if isinstance(err, InvalidInput):
614                print 'Content-Type: text/html\n'
615                e = revertStandardError()
616                print invalidInput(operation, u, fields, err, e)
617                sys.exit(1)
[153]618        print 'Content-Type: text/plain\n'
[205]619        print 'Uh-oh!  We experienced an error.'
620        print 'Please email sipb-xen@mit.edu with the contents of this page.'
621        print '----'
622        e = revertStandardError()
[153]623        print e
624        print '----'
625        raise
[209]626
627if __name__ == '__main__':
628    fields = cgi.FieldStorage()
629    u = getUser()
630    g.user = u
631    operation = os.environ.get('PATH_INFO', '')
632    if not operation:
633        print "Status: 301 Moved Permanently"
634        print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
635        sys.exit(0)
636
637    if operation.startswith('/'):
638        operation = operation[1:]
639    if not operation:
640        operation = 'list'
641
[248]642    if os.getenv("SIPB_XEN_PROFILE"):
643        import profile
644        profile.run('main(operation, u, fields)', 'log-'+operation)
645    else:
646        main(operation, u, fields)
Note: See TracBrowser for help on using the repository browser.