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