31465f606c9a33d067619c34df479f654b059037
[invirt/packages/invirt-web.git] / code / main.py
1 #!/usr/bin/python
2 """Main CGI script for web interface"""
3
4 import base64
5 import cPickle
6 import cgi
7 import datetime
8 import hmac
9 import os
10 import sha
11 import simplejson
12 import sys
13 import time
14 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, MachineAccess
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(owner, memory, on=True)
150     
151     disk_size = fields.getfirst('disk')
152     disk_size = validation.validDisk(owner, 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 = g.machines
187     checkpoint.checkpoint('Got my machines')
188     on = {}
189     has_vnc = {}
190     on = g.uptimes
191     checkpoint.checkpoint('Got uptimes')
192     for m in machines:
193         m.uptime = g.uptimes.get(m)
194         if not on[m]:
195             has_vnc[m] = 'Off'
196         elif m.type.hvm:
197             has_vnc[m] = True
198         else:
199             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
200     max_memory = validation.maxMemory(user)
201     max_disk = validation.maxDisk(user)
202     checkpoint.checkpoint('Got max mem/disk')
203     defaults = Defaults(max_memory=max_memory,
204                         max_disk=max_disk,
205                         owner=user,
206                         cdrom='gutsy-i386')
207     checkpoint.checkpoint('Got defaults')
208     d = dict(user=user,
209              cant_add_vm=validation.cantAddVm(user),
210              max_memory=max_memory,
211              max_disk=max_disk,
212              defaults=defaults,
213              machines=machines,
214              has_vnc=has_vnc,
215              uptimes=g.uptimes,
216              cdroms=CDROM.select())
217     return d
218
219 def listVms(user, fields):
220     """Handler for list requests."""
221     checkpoint.checkpoint('Getting list dict')
222     d = getListDict(user)
223     checkpoint.checkpoint('Got list dict')
224     return templates.list(searchList=[d])
225             
226 def vnc(user, fields):
227     """VNC applet page.
228
229     Note that due to same-domain restrictions, the applet connects to
230     the webserver, which needs to forward those requests to the xen
231     server.  The Xen server runs another proxy that (1) authenticates
232     and (2) finds the correct port for the VM.
233
234     You might want iptables like:
235
236     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
237       --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 
238     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
239       --dport 10003 -j SNAT --to-source 18.187.7.142 
240     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
241       --dport 10003 -j ACCEPT
242
243     Remember to enable iptables!
244     echo 1 > /proc/sys/net/ipv4/ip_forward
245     """
246     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
247     
248     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
249
250     data = {}
251     data["user"] = user
252     data["machine"] = machine.name
253     data["expires"] = time.time()+(5*60)
254     pickled_data = cPickle.dumps(data)
255     m = hmac.new(TOKEN_KEY, digestmod=sha)
256     m.update(pickled_data)
257     token = {'data': pickled_data, 'digest': m.digest()}
258     token = cPickle.dumps(token)
259     token = base64.urlsafe_b64encode(token)
260     
261     status = controls.statusInfo(machine)
262     has_vnc = hasVnc(status)
263     
264     d = dict(user=user,
265              on=status,
266              has_vnc=has_vnc,
267              machine=machine,
268              hostname=os.environ.get('SERVER_NAME', 'localhost'),
269              authtoken=token)
270     return templates.vnc(searchList=[d])
271
272 def getHostname(nic):
273     if nic.hostname and '.' in nic.hostname:
274         return nic.hostname
275     elif nic.machine:
276         return nic.machine.name + '.servers.csail.mit.edu'
277     else:
278         return None
279
280
281 def getNicInfo(data_dict, machine):
282     """Helper function for info, get data on nics for a machine.
283
284     Modifies data_dict to include the relevant data, and returns a list
285     of (key, name) pairs to display "name: data_dict[key]" to the user.
286     """
287     data_dict['num_nics'] = len(machine.nics)
288     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
289                            ('nic%s_mac', 'NIC %s MAC Addr'),
290                            ('nic%s_ip', 'NIC %s IP'),
291                            ]
292     nic_fields = []
293     for i in range(len(machine.nics)):
294         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
295         if not i:
296             data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
297         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
298         data_dict['nic%s_ip' % i] = machine.nics[i].ip
299     if len(machine.nics) == 1:
300         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
301     return nic_fields
302
303 def getDiskInfo(data_dict, machine):
304     """Helper function for info, get data on disks for a machine.
305
306     Modifies data_dict to include the relevant data, and returns a list
307     of (key, name) pairs to display "name: data_dict[key]" to the user.
308     """
309     data_dict['num_disks'] = len(machine.disks)
310     disk_fields_template = [('%s_size', '%s size')]
311     disk_fields = []
312     for disk in machine.disks:
313         name = disk.guest_device_name
314         disk_fields.extend([(x % name, y % name) for x, y in 
315                             disk_fields_template])
316         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
317     return disk_fields
318
319 def command(user, fields):
320     """Handler for running commands like boot and delete on a VM."""
321     back = fields.getfirst('back')
322     try:
323         d = controls.commandResult(user, fields)
324         if d['command'] == 'Delete VM':
325             back = 'list'
326     except InvalidInput, err:
327         if not back:
328             raise
329         #print >> sys.stderr, err
330         result = err
331     else:
332         result = 'Success!'
333         if not back:
334             return templates.command(searchList=[d])
335     if back == 'list':
336         g.clear() #Changed global state
337         d = getListDict(user)
338         d['result'] = result
339         return templates.list(searchList=[d])
340     elif back == 'info':
341         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
342         d = infoDict(user, machine)
343         d['result'] = result
344         return templates.info(searchList=[d])
345     else:
346         raise InvalidInput('back', back, 'Not a known back page.')
347
348 def modifyDict(user, fields):
349     olddisk = {}
350     transaction = ctx.current.create_transaction()
351     try:
352         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
353         owner = validation.testOwner(user, fields.getfirst('owner'), machine)
354         admin = validation.testAdmin(user, fields.getfirst('administrator'),
355                                      machine)
356         contact = validation.testContact(user, fields.getfirst('contact'),
357                                          machine)
358         name = validation.testName(user, fields.getfirst('name'), machine)
359         oldname = machine.name
360         command = "modify"
361
362         memory = fields.getfirst('memory')
363         if memory is not None:
364             memory = validation.validMemory(user, memory, machine, on=False)
365             machine.memory = memory
366  
367         disksize = validation.testDisk(user, fields.getfirst('disk'))
368         if disksize is not None:
369             disksize = validation.validDisk(user, disksize, machine)
370             disk = machine.disks[0]
371             if disk.size != disksize:
372                 olddisk[disk.guest_device_name] = disksize
373                 disk.size = disksize
374                 ctx.current.save(disk)
375         
376         if owner is not None:
377             machine.owner = owner
378         if name is not None:
379             machine.name = name
380         if admin is not None:
381             machine.administrator = admin
382         if contact is not None:
383             machine.contact = contact
384             
385         ctx.current.save(machine)
386         transaction.commit()
387     except:
388         transaction.rollback()
389         raise
390     for diskname in olddisk:
391         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
392     if name is not None:
393         controls.renameMachine(machine, oldname, name)
394     return dict(user=user,
395                 command=command,
396                 machine=machine)
397     
398 def modify(user, fields):
399     """Handler for modifying attributes of a machine."""
400     try:
401         modify_dict = modifyDict(user, fields)
402     except InvalidInput, err:
403         result = None
404         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
405     else:
406         machine = modify_dict['machine']
407         result = 'Success!'
408         err = None
409     info_dict = infoDict(user, machine)
410     info_dict['err'] = err
411     if err:
412         for field in fields.keys():
413             setattr(info_dict['defaults'], field, fields.getfirst(field))
414     info_dict['result'] = result
415     return templates.info(searchList=[info_dict])
416     
417
418 def helpHandler(user, fields):
419     """Handler for help messages."""
420     simple = fields.getfirst('simple')
421     subjects = fields.getlist('subject')
422     
423     help_mapping = dict(paravm_console="""
424 ParaVM machines do not support console access over VNC.  To access
425 these machines, you either need to boot with a liveCD and ssh in or
426 hope that the sipb-xen maintainers add support for serial consoles.""",
427                         hvm_paravm="""
428 HVM machines use the virtualization features of the processor, while
429 ParaVM machines use Xen's emulation of virtualization features.  You
430 want an HVM virtualized machine.""",
431                         cpu_weight="""
432 Don't ask us!  We're as mystified as you are.""",
433                         owner="""
434 The owner field is used to determine <a
435 href="help?subject=quotas">quotas</a>.  It must be the name of a
436 locker that you are an AFS administrator of.  In particular, you or an
437 AFS group you are a member of must have AFS rlidwka bits on the
438 locker.  You can check see who administers the LOCKER locker using the
439 command 'fs la /mit/LOCKER' on Athena.)  See also <a
440 href="help?subject=administrator">administrator</a>.""",
441                         administrator="""
442 The administrator field determines who can access the console and
443 power on and off the machine.  This can be either a user or a moira
444 group.""",
445                         quotas="""
446 Quotas are determined on a per-locker basis.  Each quota may have a
447 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
448 active machines.""",
449                         console="""
450 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
451 setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
452 your machine will run just fine, but the applet's display of the
453 console will suffer artifacts.
454 """
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     username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
587     return username
588
589 def main(operation, user, fields):    
590     start_time = time.time()
591     fun = mapping.get(operation, badOperation)
592
593     if fun not in (helpHandler, ):
594         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
595     try:
596         checkpoint.checkpoint('Before')
597         output = fun(u, fields)
598         checkpoint.checkpoint('After')
599
600         headers = dict(DEFAULT_HEADERS)
601         if isinstance(output, tuple):
602             new_headers, output = output
603             headers.update(new_headers)
604         e = revertStandardError()
605         if e:
606             output.addError(e)
607         printHeaders(headers)
608         output_string =  str(output)
609         checkpoint.checkpoint('output as a string')
610         print output_string
611         print '<pre>%s</pre>' % checkpoint
612     except Exception, err:
613         if not fields.has_key('js'):
614             if isinstance(err, CodeError):
615                 print 'Content-Type: text/html\n'
616                 e = revertStandardError()
617                 print error(operation, u, fields, err, e)
618                 sys.exit(1)
619             if isinstance(err, InvalidInput):
620                 print 'Content-Type: text/html\n'
621                 e = revertStandardError()
622                 print invalidInput(operation, u, fields, err, e)
623                 sys.exit(1)
624         print 'Content-Type: text/plain\n'
625         print 'Uh-oh!  We experienced an error.'
626         print 'Please email sipb-xen@mit.edu with the contents of this page.'
627         print '----'
628         e = revertStandardError()
629         print e
630         print '----'
631         raise
632
633 if __name__ == '__main__':
634     fields = cgi.FieldStorage()
635     u = getUser()
636     g.user = u
637     operation = os.environ.get('PATH_INFO', '')
638     if not operation:
639         print "Status: 301 Moved Permanently"
640         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
641         sys.exit(0)
642
643     if operation.startswith('/'):
644         operation = operation[1:]
645     if not operation:
646         operation = 'list'
647
648     if os.getenv("SIPB_XEN_PROFILE"):
649         import profile
650         profile.run('main(operation, u, fields)', 'log-'+operation)
651     else:
652         main(operation, u, fields)