source: trunk/web/templates/main.py @ 148

Last change on this file since 148 was 147, checked in by ecprice, 17 years ago

For new naming scheme.

  • Property svn:executable set to *
File size: 23.0 KB
Line 
1#!/usr/bin/python
2
3import sys
4import cgi
5import os
6import string
7import subprocess
8import re
9import time
10import cPickle
11import base64
12import sha
13import hmac
14import datetime
15
16sys.stderr = sys.stdout
17sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
18
19from Cheetah.Template import Template
20from sipb_xen_database import *
21import random
22
23class MyException(Exception):
24    """Base class for my exceptions"""
25    pass
26
27class InvalidInput(MyException):
28    """Exception for user-provided input is invalid but maybe in good faith.
29
30    This would include setting memory to negative (which might be a
31    typo) but not setting an invalid boot CD (which requires bypassing
32    the select box).
33    """
34    pass
35
36class CodeError(MyException):
37    """Exception for internal errors or bad faith input."""
38    pass
39
40
41
42def helppopup(subj):
43    """Return HTML code for a (?) link to a specified help topic"""
44    return '<span class="helplink"><a href="help?subject='+subj+'&amp;simple=true" target="_blank" onclick="return helppopup(\''+subj+'\')">(?)</a></span>'
45
46
47global_dict = {}
48global_dict['helppopup'] = helppopup
49
50
51# ... and stolen from xend/uuid.py
52def randomUUID():
53    """Generate a random UUID."""
54
55    return [ random.randint(0, 255) for _ in range(0, 16) ]
56
57def uuidToString(u):
58    """Turn a numeric UUID to a hyphen-seperated one."""
59    return "-".join(["%02x" * 4, "%02x" * 2, "%02x" * 2, "%02x" * 2,
60                     "%02x" * 6]) % tuple(u)
61
62MAX_MEMORY_TOTAL = 512
63MAX_MEMORY_SINGLE = 256
64MIN_MEMORY_SINGLE = 16
65MAX_DISK_TOTAL = 50
66MAX_DISK_SINGLE = 50
67MIN_DISK_SINGLE = 0.1
68MAX_VMS_TOTAL = 10
69MAX_VMS_ACTIVE = 4
70
71def getMachinesByOwner(owner):
72    """Return the machines owned by a given owner."""
73    return Machine.select_by(owner=owner)
74
75def maxMemory(user, machine=None, on=None):
76    """Return the maximum memory for a machine or a user.
77
78    If machine is None, return the memory available for a new
79    machine.  Else, return the maximum that machine can have.
80
81    on is a dictionary from machines to booleans, whether a machine is
82    on.  If None, it is recomputed. XXX make this global?
83    """
84
85    machines = getMachinesByOwner(user.username)
86    if on is None:
87        on = getUptimes(machines)
88    active_machines = [x for x in machines if on[x]]
89    mem_usage = sum([x.memory for x in active_machines if x != machine])
90    return min(MAX_MEMORY_SINGLE, MAX_MEMORY_TOTAL-mem_usage)
91
92def maxDisk(user, machine=None):
93    machines = getMachinesByOwner(user.username)
94    disk_usage = sum([sum([y.size for y in x.disks])
95                      for x in machines if x != machine])
96    return min(MAX_DISK_SINGLE, MAX_DISK_TOTAL-disk_usage/1024.)
97
98def canAddVm(user, on=None):
99    machines = getMachinesByOwner(user.username)
100    if on is None:
101        on = getUptimes(machines)
102    active_machines = [x for x in machines if on[x]]
103    return (len(machines) < MAX_VMS_TOTAL and
104            len(active_machines) < MAX_VMS_ACTIVE)
105
106def haveAccess(user, machine):
107    """Return whether a user has access to a machine"""
108    if user.username == 'moo':
109        return True
110    return machine.owner == user.username
111
112def error(op, user, fields, err):
113    """Print an error page when a CodeError occurs"""
114    d = dict(op=op, user=user, errorMessage=str(err))
115    print Template(file='error.tmpl', searchList=[d, global_dict]);
116
117def validMachineName(name):
118    """Check that name is valid for a machine name"""
119    if not name:
120        return False
121    charset = string.ascii_letters + string.digits + '-_'
122    if name[0] in '-_' or len(name) > 22:
123        return False
124    for x in name:
125        if x not in charset:
126            return False
127    return True
128
129def kinit(username = 'tabbott/extra', keytab = '/etc/tabbott.keytab'):
130    """Kinit with a given username and keytab"""
131
132    p = subprocess.Popen(['kinit', "-k", "-t", keytab, username],
133                         stderr=subprocess.PIPE)
134    e = p.wait()
135    if e:
136        raise CodeError("Error %s in kinit: %s" % (e, p.stderr.read()))
137
138def checkKinit():
139    """If we lack tickets, kinit."""
140    p = subprocess.Popen(['klist', '-s'])
141    if p.wait():
142        kinit()
143
144def remctl(*args, **kws):
145    """Perform a remctl and return the output.
146
147    kinits if necessary, and outputs errors to stderr.
148    """
149    checkKinit()
150    p = subprocess.Popen(['remctl', 'black-mesa.mit.edu']
151                         + list(args),
152                         stdout=subprocess.PIPE,
153                         stderr=subprocess.PIPE)
154    if kws.get('err'):
155        p.wait()
156        return p.stdout.read(), p.stderr.read()
157    if p.wait():
158        raise CodeError('ERROR on remctl %s: %s' %
159                          (args, p.stderr.read()))
160    return p.stdout.read()
161
162def makeDisks():
163    """Update the lvm partitions to include all disks in the database."""
164    remctl('web', 'lvcreate')
165
166def bootMachine(machine, cdtype):
167    """Boot a machine with a given boot CD.
168
169    If cdtype is None, give no boot cd.  Otherwise, it is the string
170    id of the CD (e.g. 'gutsy_i386')
171    """
172    if cdtype is not None:
173        remctl('web', 'vmboot', machine.name,
174               cdtype)
175    else:
176        remctl('web', 'vmboot', machine.name)
177
178def registerMachine(machine):
179    """Register a machine to be controlled by the web interface"""
180    remctl('web', 'register', machine.name)
181
182def unregisterMachine(machine):
183    """Unregister a machine to not be controlled by the web interface"""
184    remctl('web', 'unregister', machine.name)
185
186def parseStatus(s):
187    """Parse a status string into nested tuples of strings.
188
189    s = output of xm list --long <machine_name>
190    """
191    values = re.split('([()])', s)
192    stack = [[]]
193    for v in values[2:-2]: #remove initial and final '()'
194        if not v:
195            continue
196        v = v.strip()
197        if v == '(':
198            stack.append([])
199        elif v == ')':
200            if len(stack[-1]) == 1:
201                stack[-1].append('')
202            stack[-2].append(stack[-1])
203            stack.pop()
204        else:
205            if not v:
206                continue
207            stack[-1].extend(v.split())
208    return stack[-1]
209
210def getUptimes(machines):
211    """Return a dictionary mapping machine names to uptime strings"""
212    value_string = remctl('web', 'listvms')
213    lines = value_string.splitlines()
214    d = {}
215    for line in lines:
216        lst = line.split()
217        name, id = lst[:2]
218        uptime = ' '.join(lst[2:])
219        d[name] = uptime
220    ans = {}
221    for m in machines:
222        ans[m] = d.get(m.name)
223    return ans
224
225def statusInfo(machine):
226    """Return the status list for a given machine.
227
228    Gets and parses xm list --long
229    """
230    value_string, err_string = remctl('list-long', machine.name, err=True)
231    if 'Unknown command' in err_string:
232        raise CodeError("ERROR in remctl list-long %s is not registered" % (machine.name,))
233    elif 'does not exist' in err_string:
234        return None
235    elif err_string:
236        raise CodeError("ERROR in remctl list-long %s%s" % (machine.name, err_string))
237    status = parseStatus(value_string)
238    return status
239
240def hasVnc(status):
241    """Does the machine with a given status list support VNC?"""
242    if status is None:
243        return False
244    for l in status:
245        if l[0] == 'device' and l[1][0] == 'vfb':
246            d = dict(l[1][1:])
247            return 'location' in d
248    return False
249
250def createVm(user, name, memory, disk, is_hvm, cdrom):
251    """Create a VM and put it in the database"""
252    # put stuff in the table
253    transaction = ctx.current.create_transaction()
254    try:
255        if memory > maxMemory(user):
256            raise InvalidInput("Too much memory requested")
257        if disk > maxDisk(user) * 1024:
258            raise InvalidInput("Too much disk requested")
259        if not canAddVm(user):
260            raise InvalidInput("Too many VMs requested")
261        res = meta.engine.execute('select nextval(\'"machines_machine_id_seq"\')')
262        id = res.fetchone()[0]
263        machine = Machine()
264        machine.machine_id = id
265        machine.name = name
266        machine.memory = memory
267        machine.owner = user.username
268        machine.contact = user.email
269        machine.uuid = uuidToString(randomUUID())
270        machine.boot_off_cd = True
271        machine_type = Type.get_by(hvm=is_hvm)
272        machine.type_id = machine_type.type_id
273        ctx.current.save(machine)
274        disk = Disk(machine.machine_id, 
275                    'hda', disk)
276        open = NIC.select_by(machine_id=None)
277        if not open: #No IPs left!
278            raise CodeError("No IP addresses left!  Contact sipb-xen-dev@mit.edu")
279        nic = open[0]
280        nic.machine_id = machine.machine_id
281        nic.hostname = name
282        ctx.current.save(nic)   
283        ctx.current.save(disk)
284        transaction.commit()
285    except:
286        transaction.rollback()
287        raise
288    registerMachine(machine)
289    makeDisks()
290    # tell it to boot with cdrom
291    bootMachine(machine, cdrom)
292
293    return machine
294
295def validMemory(user, memory, machine=None):
296    """Parse and validate limits for memory for a given user and machine."""
297    try:
298        memory = int(memory)
299        if memory < MIN_MEMORY_SINGLE:
300            raise ValueError
301    except ValueError:
302        raise InvalidInput("Invalid memory amount; must be at least %s MB" %
303                          MIN_MEMORY_SINGLE)
304    if memory > maxMemory(user, machine):
305        raise InvalidInput("Too much memory requested")
306    return memory
307
308def validDisk(user, disk, machine=None):
309    """Parse and validate limits for disk for a given user and machine."""
310    try:
311        disk = float(disk)
312        if disk > maxDisk(user, machine):
313            raise InvalidInput("Too much disk requested")
314        disk = int(disk * 1024)
315        if disk < MIN_DISK_SINGLE * 1024:
316            raise ValueError
317    except ValueError:
318        raise InvalidInput("Invalid disk amount; minimum is %s GB" %
319                          MIN_DISK_SINGLE)
320    return disk
321
322def create(user, fields):
323    """Handler for create requests."""
324    name = fields.getfirst('name')
325    if not validMachineName(name):
326        raise InvalidInput("Invalid name '%s'" % name)
327    name = user.username + '_' + name.lower()
328
329    if Machine.get_by(name=name):
330        raise InvalidInput("A machine named '%s' already exists" % name)
331   
332    memory = fields.getfirst('memory')
333    memory = validMemory(user, memory)
334   
335    disk = fields.getfirst('disk')
336    disk = validDisk(user, disk)
337
338    vm_type = fields.getfirst('vmtype')
339    if vm_type not in ('hvm', 'paravm'):
340        raise CodeError("Invalid vm type '%s'"  % vm_type)   
341    is_hvm = (vm_type == 'hvm')
342
343    cdrom = fields.getfirst('cdrom')
344    if cdrom is not None and not CDROM.get(cdrom):
345        raise CodeError("Invalid cdrom type '%s'" % cdrom)   
346   
347    machine = createVm(user, name, memory, disk, is_hvm, cdrom)
348    d = dict(user=user,
349             machine=machine)
350    print Template(file='create.tmpl',
351                   searchList=[d, global_dict]);
352
353def listVms(user, fields):
354    """Handler for list requests."""
355    machines = [m for m in Machine.select() if haveAccess(user, m)]   
356    on = {}
357    has_vnc = {}
358    uptimes = getUptimes(machines)
359    on = uptimes
360    for m in machines:
361        if not on[m]:
362            has_vnc[m] = 'Off'
363        elif m.type.hvm:
364            has_vnc[m] = True
365        else:
366            has_vnc[m] = "ParaVM"+helppopup("paravm_console")
367    #     for m in machines:
368    #         status = statusInfo(m)
369    #         on[m.name] = status is not None
370    #         has_vnc[m.name] = hasVnc(status)
371    max_mem=maxMemory(user, on=on)
372    max_disk=maxDisk(user)
373    d = dict(user=user,
374             can_add_vm=canAddVm(user, on=on),
375             max_mem=max_mem,
376             max_disk=max_disk,
377             default_mem=max_mem,
378             default_disk=min(4.0, max_disk),
379             machines=machines,
380             has_vnc=has_vnc,
381             uptimes=uptimes,
382             cdroms=CDROM.select())
383    print Template(file='list.tmpl', searchList=[d, global_dict])
384
385def testMachineId(user, machineId, exists=True):
386    """Parse, validate and check authorization for a given machineId.
387
388    If exists is False, don't check that it exists.
389    """
390    if machineId is None:
391        raise CodeError("No machine ID specified")
392    try:
393        machineId = int(machineId)
394    except ValueError:
395        raise CodeError("Invalid machine ID '%s'" % machineId)
396    machine = Machine.get(machineId)
397    if exists and machine is None:
398        raise CodeError("No such machine ID '%s'" % machineId)
399    if machine is not None and not haveAccess(user, machine):
400        raise CodeError("No access to machine ID '%s'" % machineId)
401    return machine
402
403def vnc(user, fields):
404    """VNC applet page.
405
406    Note that due to same-domain restrictions, the applet connects to
407    the webserver, which needs to forward those requests to the xen
408    server.  The Xen server runs another proxy that (1) authenticates
409    and (2) finds the correct port for the VM.
410
411    You might want iptables like:
412
413    -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
414    -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp --dport 10003 -j SNAT --to-source 18.187.7.142
415    -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp --dport 10003 -j ACCEPT
416
417    Remember to enable iptables!
418    echo 1 > /proc/sys/net/ipv4/ip_forward
419    """
420    machine = testMachineId(user, fields.getfirst('machine_id'))
421   
422    TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
423
424    data = {}
425    data["user"] = user.username
426    data["machine"]=machine.name
427    data["expires"]=time.time()+(5*60)
428    pickledData = cPickle.dumps(data)
429    m = hmac.new(TOKEN_KEY, digestmod=sha)
430    m.update(pickledData)
431    token = {'data': pickledData, 'digest': m.digest()}
432    token = cPickle.dumps(token)
433    token = base64.urlsafe_b64encode(token)
434   
435    d = dict(user=user,
436             machine=machine,
437             hostname=os.environ.get('SERVER_NAME', 'localhost'),
438             authtoken=token)
439    print Template(file='vnc.tmpl',
440                   searchList=[d, global_dict])
441
442def getNicInfo(data_dict, machine):
443    """Helper function for info, get data on nics for a machine.
444
445    Modifies data_dict to include the relevant data, and returns a list
446    of (key, name) pairs to display "name: data_dict[key]" to the user.
447    """
448    data_dict['num_nics'] = len(machine.nics)
449    nic_fields_template = [('nic%s_hostname', 'NIC %s hostname'),
450                           ('nic%s_mac', 'NIC %s MAC Addr'),
451                           ('nic%s_ip', 'NIC %s IP'),
452                           ]
453    nic_fields = []
454    for i in range(len(machine.nics)):
455        nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
456        data_dict['nic%s_hostname' % i] = machine.nics[i].hostname + '.servers.csail.mit.edu'
457        data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
458        data_dict['nic%s_ip' % i] = machine.nics[i].ip
459    if len(machine.nics) == 1:
460        nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
461    return nic_fields
462
463def getDiskInfo(data_dict, machine):
464    """Helper function for info, get data on disks for a machine.
465
466    Modifies data_dict to include the relevant data, and returns a list
467    of (key, name) pairs to display "name: data_dict[key]" to the user.
468    """
469    data_dict['num_disks'] = len(machine.disks)
470    disk_fields_template = [('%s_size', '%s size')]
471    disk_fields = []
472    for disk in machine.disks:
473        name = disk.guest_device_name
474        disk_fields.extend([(x % name, y % name) for x, y in disk_fields_template])
475        data_dict['%s_size' % name] = "%0.1f GB" % (disk.size / 1024.)
476    return disk_fields
477
478def deleteVM(machine):
479    """Delete a VM."""
480    transaction = ctx.current.create_transaction()
481    delete_disk_pairs = [(machine.name, d.guest_device_name) for d in machine.disks]
482    try:
483        for nic in machine.nics:
484            nic.machine_id = None
485            nic.hostname = None
486            ctx.current.save(nic)
487        for disk in machine.disks:
488            ctx.current.delete(disk)
489        ctx.current.delete(machine)
490        transaction.commit()
491    except:
492        transaction.rollback()
493        raise
494    for mname, dname in delete_disk_pairs:
495        remctl('web', 'lvremove', mname, dname)
496    unregisterMachine(machine)
497
498def command(user, fields):
499    """Handler for running commands like boot and delete on a VM."""
500    print time.time()-start_time
501    machine = testMachineId(user, fields.getfirst('machine_id'))
502    action = fields.getfirst('action')
503    cdrom = fields.getfirst('cdrom')
504    print time.time()-start_time
505    if cdrom is not None and not CDROM.get(cdrom):
506        raise CodeError("Invalid cdrom type '%s'" % cdrom)   
507    if action not in ('Reboot', 'Power on', 'Power off', 'Shutdown', 'Delete VM'):
508        raise CodeError("Invalid action '%s'" % action)
509    if action == 'Reboot':
510        if cdrom is not None:
511            remctl('reboot', machine.name, cdrom)
512        else:
513            remctl('reboot', machine.name)
514    elif action == 'Power on':
515        if maxMemory(user) < machine.memory:
516            raise InvalidInput("You don't have enough free RAM quota")
517        bootMachine(machine, cdrom)
518    elif action == 'Power off':
519        remctl('destroy', machine.name)
520    elif action == 'Shutdown':
521        remctl('shutdown', machine.name)
522    elif action == 'Delete VM':
523        deleteVM(machine)
524    print time.time()-start_time
525
526    d = dict(user=user,
527             command=action,
528             machine=machine)
529    print Template(file="command.tmpl", searchList=[d, global_dict])
530       
531def modify(user, fields):
532    """Handler for modifying attributes of a machine."""
533    #XXX not written yet
534    machine = testMachineId(user, fields.getfirst('machine_id'))
535   
536def help(user, fields):
537    """Handler for help messages."""
538    simple = fields.getfirst('simple')
539    subjects = fields.getlist('subject')
540   
541    mapping = dict(paravm_console="""
542ParaVM machines do not support console access over VNC.  To access
543these machines, you either need to boot with a liveCD and ssh in or
544hope that the sipb-xen maintainers add support for serial consoles.""",
545                   hvm_paravm="""
546HVM machines use the virtualization features of the processor, while
547ParaVM machines use Xen's emulation of virtualization features.  You
548want an HVM virtualized machine.""",
549                   cpu_weight="""Don't ask us!  We're as mystified as you are.""")
550   
551    d = dict(user=user,
552             simple=simple,
553             subjects=subjects,
554             mapping=mapping)
555   
556    print Template(file="help.tmpl", searchList=[d, global_dict])
557   
558
559def info(user, fields):
560    """Handler for info on a single VM."""
561    machine = testMachineId(user, fields.getfirst('machine_id'))
562    status = statusInfo(machine)
563    has_vnc = hasVnc(status)
564    if status is None:
565        main_status = dict(name=machine.name,
566                           memory=str(machine.memory))
567    else:
568        main_status = dict(status[1:])
569    start_time = float(main_status.get('start_time', 0))
570    uptime = datetime.timedelta(seconds=int(time.time()-start_time))
571    cpu_time_float = float(main_status.get('cpu_time', 0))
572    cputime = datetime.timedelta(seconds=int(cpu_time_float))
573    display_fields = """name uptime memory state cpu_weight on_reboot
574     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
575    display_fields = [('name', 'Name'),
576                      ('owner', 'Owner'),
577                      ('contact', 'Contact'),
578                      ('type', 'Type'),
579                      'NIC_INFO',
580                      ('uptime', 'uptime'),
581                      ('cputime', 'CPU usage'),
582                      ('memory', 'RAM'),
583                      'DISK_INFO',
584                      ('state', 'state (xen format)'),
585                      ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
586                      ('on_reboot', 'Action on VM reboot'),
587                      ('on_poweroff', 'Action on VM poweroff'),
588                      ('on_crash', 'Action on VM crash'),
589                      ('on_xend_start', 'Action on Xen start'),
590                      ('on_xend_stop', 'Action on Xen stop'),
591                      ('bootloader', 'Bootloader options'),
592                      ]
593    fields = []
594    machine_info = {}
595    machine_info['name'] = machine.name
596    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
597    machine_info['owner'] = machine.owner
598    machine_info['contact'] = machine.contact
599
600    nic_fields = getNicInfo(machine_info, machine)
601    nic_point = display_fields.index('NIC_INFO')
602    display_fields = display_fields[:nic_point] + nic_fields + display_fields[nic_point+1:]
603
604    disk_fields = getDiskInfo(machine_info, machine)
605    disk_point = display_fields.index('DISK_INFO')
606    display_fields = display_fields[:disk_point] + disk_fields + display_fields[disk_point+1:]
607   
608    main_status['memory'] += ' MB'
609    for field, disp in display_fields:
610        if field in ('uptime', 'cputime'):
611            fields.append((disp, locals()[field]))
612        elif field in machine_info:
613            fields.append((disp, machine_info[field]))
614        elif field in main_status:
615            fields.append((disp, main_status[field]))
616        else:
617            pass
618            #fields.append((disp, None))
619    max_mem = maxMemory(user, machine)
620    max_disk = maxDisk(user, machine)
621    d = dict(user=user,
622             cdroms=CDROM.select(),
623             on=status is not None,
624             machine=machine,
625             has_vnc=has_vnc,
626             uptime=str(uptime),
627             ram=machine.memory,
628             max_mem=max_mem,
629             max_disk=max_disk,
630             fields = fields)
631    print Template(file='info.tmpl',
632                   searchList=[d, global_dict])
633
634mapping = dict(list=listVms,
635               vnc=vnc,
636               command=command,
637               modify=modify,
638               info=info,
639               create=create,
640               help=help)
641
642if __name__ == '__main__':
643    start_time = time.time()
644    fields = cgi.FieldStorage()
645    class User:
646        username = "moo"
647        email = 'moo@cow.com'
648    u = User()
649    if 'SSL_CLIENT_S_DN_Email' in os.environ:
650        username = os.environ[ 'SSL_CLIENT_S_DN_Email'].split("@")[0]
651        u.username = username
652        u.email = os.environ[ 'SSL_CLIENT_S_DN_Email']
653    else:
654        u.username = 'moo'
655        u.email = 'nobody'
656    connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
657    operation = os.environ.get('PATH_INFO', '')
658    #print 'Content-Type: text/plain\n'
659    #print operation
660    if not operation:
661        print "Status: 301 Moved Permanently"
662        print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
663        sys.exit(0)
664    print 'Content-Type: text/html\n'
665
666    if operation.startswith('/'):
667        operation = operation[1:]
668    if not operation:
669        operation = 'list'
670   
671    fun = mapping.get(operation, 
672                      lambda u, e:
673                          error(operation, u, e,
674                                "Invalid operation '%s'" % operation))
675    if fun not in (help, ):
676        connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
677    try:
678        fun(u, fields)
679    except CodeError, err:
680        error(operation, u, fields, err)
681    except InvalidInput, err:
682        error(operation, u, fields, err)
Note: See TracBrowser for help on using the repository browser.