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