e1820951d788cce7e564011e9431fc1806bac7d9
[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 getHostname(nic):
278     if nic.hostname and '.' in nic.hostname:
279         return nic.hostname
280     elif nic.machine:
281         return nic.machine.name + '.servers.csail.mit.edu'
282     else:
283         return None
284
285
286 def getNicInfo(data_dict, machine):
287     """Helper function for info, get data on nics for a machine.
288
289     Modifies data_dict to include the relevant data, and returns a list
290     of (key, name) pairs to display "name: data_dict[key]" to the user.
291     """
292     data_dict['num_nics'] = len(machine.nics)
293     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
294                            ('nic%s_mac', 'NIC %s MAC Addr'),
295                            ('nic%s_ip', 'NIC %s IP'),
296                            ]
297     nic_fields = []
298     for i in range(len(machine.nics)):
299         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
300         if not i:
301             data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
302         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
303         data_dict['nic%s_ip' % i] = machine.nics[i].ip
304     if len(machine.nics) == 1:
305         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
306     return nic_fields
307
308 def getDiskInfo(data_dict, machine):
309     """Helper function for info, get data on disks for a machine.
310
311     Modifies data_dict to include the relevant data, and returns a list
312     of (key, name) pairs to display "name: data_dict[key]" to the user.
313     """
314     data_dict['num_disks'] = len(machine.disks)
315     disk_fields_template = [('%s_size', '%s size')]
316     disk_fields = []
317     for disk in machine.disks:
318         name = disk.guest_device_name
319         disk_fields.extend([(x % name, y % name) for x, y in 
320                             disk_fields_template])
321         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
322     return disk_fields
323
324 def command(user, fields):
325     """Handler for running commands like boot and delete on a VM."""
326     back = fields.getfirst('back')
327     try:
328         d = controls.commandResult(user, fields)
329         if d['command'] == 'Delete VM':
330             back = 'list'
331     except InvalidInput, err:
332         if not back:
333             raise
334         print >> sys.stderr, err
335         result = None
336     else:
337         result = 'Success!'
338         if not back:
339             return templates.command(searchList=[d])
340     if back == 'list':
341         g.clear() #Changed global state
342         d = getListDict(user)
343         d['result'] = result
344         return templates.list(searchList=[d])
345     elif back == 'info':
346         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
347         d = infoDict(user, machine)
348         d['result'] = result
349         return templates.info(searchList=[d])
350     else:
351         raise InvalidInput
352     ('back', back, 'Not a known back page.')
353
354 def modifyDict(user, fields):
355     olddisk = {}
356     transaction = ctx.current.create_transaction()
357     try:
358         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
359         owner = validation.testOwner(user, fields.getfirst('owner'), machine)
360         admin = validation.testAdmin(user, fields.getfirst('administrator'),
361                                      machine)
362         contact = validation.testContact(user, fields.getfirst('contact'),
363                                          machine)
364         name = validation.testName(user, fields.getfirst('name'), machine)
365         oldname = machine.name
366         command = "modify"
367
368         memory = fields.getfirst('memory')
369         if memory is not None:
370             memory = validation.validMemory(user, memory, machine, on=False)
371             machine.memory = memory
372  
373         disksize = validation.testDisk(user, fields.getfirst('disk'))
374         if disksize is not None:
375             disksize = validation.validDisk(user, disksize, machine)
376             disk = machine.disks[0]
377             if disk.size != disksize:
378                 olddisk[disk.guest_device_name] = disksize
379                 disk.size = disksize
380                 ctx.current.save(disk)
381         
382         if owner is not None:
383             machine.owner = owner
384         if name is not None:
385             machine.name = name
386         if admin is not None:
387             machine.administrator = admin
388         if contact is not None:
389             machine.contact = contact
390             
391         ctx.current.save(machine)
392         transaction.commit()
393     except:
394         transaction.rollback()
395         raise
396     for diskname in olddisk:
397         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
398     if name is not None:
399         controls.renameMachine(machine, oldname, name)
400     return dict(user=user,
401                 command=command,
402                 machine=machine)
403     
404 def modify(user, fields):
405     """Handler for modifying attributes of a machine."""
406     try:
407         modify_dict = modifyDict(user, fields)
408     except InvalidInput, err:
409         result = None
410         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
411     else:
412         machine = modify_dict['machine']
413         result = 'Success!'
414         err = None
415     info_dict = infoDict(user, machine)
416     info_dict['err'] = err
417     if err:
418         for field in fields.keys():
419             setattr(info_dict['defaults'], field, fields.getfirst(field))
420     info_dict['result'] = result
421     return templates.info(searchList=[info_dict])
422     
423
424 def helpHandler(user, fields):
425     """Handler for help messages."""
426     simple = fields.getfirst('simple')
427     subjects = fields.getlist('subject')
428     
429     help_mapping = dict(paravm_console="""
430 ParaVM machines do not support console access over VNC.  To access
431 these machines, you either need to boot with a liveCD and ssh in or
432 hope that the sipb-xen maintainers add support for serial consoles.""",
433                         hvm_paravm="""
434 HVM machines use the virtualization features of the processor, while
435 ParaVM machines use Xen's emulation of virtualization features.  You
436 want an HVM virtualized machine.""",
437                         cpu_weight="""
438 Don't ask us!  We're as mystified as you are.""",
439                         owner="""
440 The owner field is used to determine <a
441 href="help?subject=quotas">quotas</a>.  It must be the name of a
442 locker that you are an AFS administrator of.  In particular, you or an
443 AFS group you are a member of must have AFS rlidwka bits on the
444 locker.  You can check see who administers the LOCKER locker using the
445 command 'fs la /mit/LOCKER' on Athena.)  See also <a
446 href="help?subject=administrator">administrator</a>.""",
447                         administrator="""
448 The administrator field determines who can access the console and
449 power on and off the machine.  This can be either a user or a moira
450 group.""",
451                         quotas="""
452 Quotas are determined on a per-locker basis.  Each quota may have a
453 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
454 active machines."""
455                    )
456     
457     if not subjects:
458         subjects = sorted(help_mapping.keys())
459         
460     d = dict(user=user,
461              simple=simple,
462              subjects=subjects,
463              mapping=help_mapping)
464     
465     return templates.help(searchList=[d])
466     
467
468 def badOperation(u, e):
469     raise CodeError("Unknown operation")
470
471 def infoDict(user, machine):
472     status = controls.statusInfo(machine)
473     checkpoint.checkpoint('Getting status info')
474     has_vnc = hasVnc(status)
475     if status is None:
476         main_status = dict(name=machine.name,
477                            memory=str(machine.memory))
478         uptime = None
479         cputime = None
480     else:
481         main_status = dict(status[1:])
482         start_time = float(main_status.get('start_time', 0))
483         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
484         cpu_time_float = float(main_status.get('cpu_time', 0))
485         cputime = datetime.timedelta(seconds=int(cpu_time_float))
486     checkpoint.checkpoint('Status')
487     display_fields = """name uptime memory state cpu_weight on_reboot 
488      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
489     display_fields = [('name', 'Name'),
490                       ('owner', 'Owner'),
491                       ('administrator', 'Administrator'),
492                       ('contact', 'Contact'),
493                       ('type', 'Type'),
494                       'NIC_INFO',
495                       ('uptime', 'uptime'),
496                       ('cputime', 'CPU usage'),
497                       ('memory', 'RAM'),
498                       'DISK_INFO',
499                       ('state', 'state (xen format)'),
500                       ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
501                       ('on_reboot', 'Action on VM reboot'),
502                       ('on_poweroff', 'Action on VM poweroff'),
503                       ('on_crash', 'Action on VM crash'),
504                       ('on_xend_start', 'Action on Xen start'),
505                       ('on_xend_stop', 'Action on Xen stop'),
506                       ('bootloader', 'Bootloader options'),
507                       ]
508     fields = []
509     machine_info = {}
510     machine_info['name'] = machine.name
511     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
512     machine_info['owner'] = machine.owner
513     machine_info['administrator'] = machine.administrator
514     machine_info['contact'] = machine.contact
515
516     nic_fields = getNicInfo(machine_info, machine)
517     nic_point = display_fields.index('NIC_INFO')
518     display_fields = (display_fields[:nic_point] + nic_fields + 
519                       display_fields[nic_point+1:])
520
521     disk_fields = getDiskInfo(machine_info, machine)
522     disk_point = display_fields.index('DISK_INFO')
523     display_fields = (display_fields[:disk_point] + disk_fields + 
524                       display_fields[disk_point+1:])
525     
526     main_status['memory'] += ' MiB'
527     for field, disp in display_fields:
528         if field in ('uptime', 'cputime') and locals()[field] is not None:
529             fields.append((disp, locals()[field]))
530         elif field in machine_info:
531             fields.append((disp, machine_info[field]))
532         elif field in main_status:
533             fields.append((disp, main_status[field]))
534         else:
535             pass
536             #fields.append((disp, None))
537
538     checkpoint.checkpoint('Got fields')
539
540
541     max_mem = validation.maxMemory(user, machine, False)
542     checkpoint.checkpoint('Got mem')
543     max_disk = validation.maxDisk(user, machine)
544     defaults = Defaults()
545     for name in 'machine_id name administrator owner memory contact'.split():
546         setattr(defaults, name, getattr(machine, name))
547     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
548     checkpoint.checkpoint('Got defaults')
549     d = dict(user=user,
550              cdroms=CDROM.select(),
551              on=status is not None,
552              machine=machine,
553              defaults=defaults,
554              has_vnc=has_vnc,
555              uptime=str(uptime),
556              ram=machine.memory,
557              max_mem=max_mem,
558              max_disk=max_disk,
559              owner_help=helppopup("owner"),
560              fields = fields)
561     return d
562
563 def info(user, fields):
564     """Handler for info on a single VM."""
565     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
566     d = infoDict(user, machine)
567     checkpoint.checkpoint('Got infodict')
568     return templates.info(searchList=[d])
569
570 mapping = dict(list=listVms,
571                vnc=vnc,
572                command=command,
573                modify=modify,
574                info=info,
575                create=create,
576                help=helpHandler)
577
578 def printHeaders(headers):
579     for key, value in headers.iteritems():
580         print '%s: %s' % (key, value)
581     print
582
583
584 def getUser():
585     """Return the current user based on the SSL environment variables"""
586     if 'SSL_CLIENT_S_DN_Email' in os.environ:
587         username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
588         return username
589     else:
590         return 'moo'
591
592 def main(operation, user, fields):    
593     start_time = time.time()
594     fun = mapping.get(operation, badOperation)
595
596     if fun not in (helpHandler, ):
597         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
598     try:
599         checkpoint.checkpoint('Before')
600         output = fun(u, fields)
601         checkpoint.checkpoint('After')
602
603         headers = dict(DEFAULT_HEADERS)
604         if isinstance(output, tuple):
605             new_headers, output = output
606             headers.update(new_headers)
607         e = revertStandardError()
608         if e:
609             output.addError(e)
610         printHeaders(headers)
611         output_string =  str(output)
612         checkpoint.checkpoint('output as a string')
613         print output_string
614         print '<pre>%s</pre>' % checkpoint
615     except Exception, err:
616         if not fields.has_key('js'):
617             if isinstance(err, CodeError):
618                 print 'Content-Type: text/html\n'
619                 e = revertStandardError()
620                 print error(operation, u, fields, err, e)
621                 sys.exit(1)
622             if isinstance(err, InvalidInput):
623                 print 'Content-Type: text/html\n'
624                 e = revertStandardError()
625                 print invalidInput(operation, u, fields, err, e)
626                 sys.exit(1)
627         print 'Content-Type: text/plain\n'
628         print 'Uh-oh!  We experienced an error.'
629         print 'Please email sipb-xen@mit.edu with the contents of this page.'
630         print '----'
631         e = revertStandardError()
632         print e
633         print '----'
634         raise
635
636 if __name__ == '__main__':
637     fields = cgi.FieldStorage()
638     u = getUser()
639     g.user = u
640     operation = os.environ.get('PATH_INFO', '')
641     if not operation:
642         print "Status: 301 Moved Permanently"
643         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
644         sys.exit(0)
645
646     if operation.startswith('/'):
647         operation = operation[1:]
648     if not operation:
649         operation = 'list'
650
651     if os.getenv("SIPB_XEN_PROFILE"):
652         import profile
653         profile.run('main(operation, u, fields)', 'log-'+operation)
654     else:
655         main(operation, u, fields)