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