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