Revert to old list method until we start updating the cache.
[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_size = fields.getfirst('disk')
153     disk_size = validation.validDisk(user, disk_size)
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_size=disk_size,
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     #if user == 'moo':
190     #    machines = Machine.select()
191     #else:
192     #    machines = Machine.query().join('users').filter_by(user=user).all()
193     checkpoint.checkpoint('Got my machines')
194     on = {}
195     has_vnc = {}
196     on = g.uptimes
197     checkpoint.checkpoint('Got uptimes')
198     for m in machines:
199         m.uptime = g.uptimes.get(m)
200         if not on[m]:
201             has_vnc[m] = 'Off'
202         elif m.type.hvm:
203             has_vnc[m] = True
204         else:
205             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
206     max_memory = validation.maxMemory(user)
207     max_disk = validation.maxDisk(user)
208     checkpoint.checkpoint('Got max mem/disk')
209     defaults = Defaults(max_memory=max_memory,
210                         max_disk=max_disk,
211                         owner=user,
212                         cdrom='gutsy-i386')
213     checkpoint.checkpoint('Got defaults')
214     d = dict(user=user,
215              cant_add_vm=validation.cantAddVm(user),
216              max_memory=max_memory,
217              max_disk=max_disk,
218              defaults=defaults,
219              machines=machines,
220              has_vnc=has_vnc,
221              uptimes=g.uptimes,
222              cdroms=CDROM.select())
223     return d
224
225 def listVms(user, fields):
226     """Handler for list requests."""
227     checkpoint.checkpoint('Getting list dict')
228     d = getListDict(user)
229     checkpoint.checkpoint('Got list dict')
230     return templates.list(searchList=[d])
231             
232 def vnc(user, fields):
233     """VNC applet page.
234
235     Note that due to same-domain restrictions, the applet connects to
236     the webserver, which needs to forward those requests to the xen
237     server.  The Xen server runs another proxy that (1) authenticates
238     and (2) finds the correct port for the VM.
239
240     You might want iptables like:
241
242     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
243       --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 
244     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
245       --dport 10003 -j SNAT --to-source 18.187.7.142 
246     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
247       --dport 10003 -j ACCEPT
248
249     Remember to enable iptables!
250     echo 1 > /proc/sys/net/ipv4/ip_forward
251     """
252     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
253     
254     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
255
256     data = {}
257     data["user"] = user
258     data["machine"] = machine.name
259     data["expires"] = time.time()+(5*60)
260     pickled_data = cPickle.dumps(data)
261     m = hmac.new(TOKEN_KEY, digestmod=sha)
262     m.update(pickled_data)
263     token = {'data': pickled_data, 'digest': m.digest()}
264     token = cPickle.dumps(token)
265     token = base64.urlsafe_b64encode(token)
266     
267     status = controls.statusInfo(machine)
268     has_vnc = hasVnc(status)
269     
270     d = dict(user=user,
271              on=status,
272              has_vnc=has_vnc,
273              machine=machine,
274              hostname=os.environ.get('SERVER_NAME', 'localhost'),
275              authtoken=token)
276     return templates.vnc(searchList=[d])
277
278 def getNicInfo(data_dict, machine):
279     """Helper function for info, get data on nics for a machine.
280
281     Modifies data_dict to include the relevant data, and returns a list
282     of (key, name) pairs to display "name: data_dict[key]" to the user.
283     """
284     data_dict['num_nics'] = len(machine.nics)
285     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
286                            ('nic%s_mac', 'NIC %s MAC Addr'),
287                            ('nic%s_ip', 'NIC %s IP'),
288                            ]
289     nic_fields = []
290     for i in range(len(machine.nics)):
291         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
292         if not i:
293             data_dict['nic%s_hostname' % i] = (machine.name + 
294                                                '.servers.csail.mit.edu')
295         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
296         data_dict['nic%s_ip' % i] = machine.nics[i].ip
297     if len(machine.nics) == 1:
298         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
299     return nic_fields
300
301 def getDiskInfo(data_dict, machine):
302     """Helper function for info, get data on disks for a machine.
303
304     Modifies data_dict to include the relevant data, and returns a list
305     of (key, name) pairs to display "name: data_dict[key]" to the user.
306     """
307     data_dict['num_disks'] = len(machine.disks)
308     disk_fields_template = [('%s_size', '%s size')]
309     disk_fields = []
310     for disk in machine.disks:
311         name = disk.guest_device_name
312         disk_fields.extend([(x % name, y % name) for x, y in 
313                             disk_fields_template])
314         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
315     return disk_fields
316
317 def command(user, fields):
318     """Handler for running commands like boot and delete on a VM."""
319     back = fields.getfirst('back')
320     try:
321         d = controls.commandResult(user, fields)
322         if d['command'] == 'Delete VM':
323             back = 'list'
324     except InvalidInput, err:
325         if not back:
326             raise
327         print >> sys.stderr, err
328         result = None
329     else:
330         result = 'Success!'
331         if not back:
332             return templates.command(searchList=[d])
333     if back == 'list':
334         g.clear() #Changed global state
335         d = getListDict(user)
336         d['result'] = result
337         return templates.list(searchList=[d])
338     elif back == 'info':
339         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
340         d = infoDict(user, machine)
341         d['result'] = result
342         return templates.info(searchList=[d])
343     else:
344         raise InvalidInput
345     ('back', back, 'Not a known back page.')
346
347 def modifyDict(user, fields):
348     olddisk = {}
349     transaction = ctx.current.create_transaction()
350     try:
351         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
352         owner = validation.testOwner(user, fields.getfirst('owner'), machine)
353         admin = validation.testAdmin(user, fields.getfirst('administrator'),
354                                      machine)
355         contact = validation.testContact(user, fields.getfirst('contact'),
356                                          machine)
357         name = validation.testName(user, fields.getfirst('name'), machine)
358         oldname = machine.name
359         command = "modify"
360
361         memory = fields.getfirst('memory')
362         if memory is not None:
363             memory = validation.validMemory(user, memory, machine, on=False)
364             machine.memory = memory
365  
366         disksize = validation.testDisk(user, fields.getfirst('disk'))
367         if disksize is not None:
368             disksize = validation.validDisk(user, disksize, machine)
369             disk = machine.disks[0]
370             if disk.size != disksize:
371                 olddisk[disk.guest_device_name] = disksize
372                 disk.size = disksize
373                 ctx.current.save(disk)
374         
375         if owner is not None:
376             machine.owner = owner
377         if name is not None:
378             machine.name = name
379         if admin is not None:
380             machine.administrator = admin
381         if contact is not None:
382             machine.contact = contact
383             
384         ctx.current.save(machine)
385         transaction.commit()
386     except:
387         transaction.rollback()
388         raise
389     for diskname in olddisk:
390         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
391     if name is not None:
392         controls.renameMachine(machine, oldname, name)
393     return dict(user=user,
394                 command=command,
395                 machine=machine)
396     
397 def modify(user, fields):
398     """Handler for modifying attributes of a machine."""
399     try:
400         modify_dict = modifyDict(user, fields)
401     except InvalidInput, err:
402         result = None
403         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
404     else:
405         machine = modify_dict['machine']
406         result = 'Success!'
407         err = None
408     info_dict = infoDict(user, machine)
409     info_dict['err'] = err
410     if err:
411         for field in fields.keys():
412             setattr(info_dict['defaults'], field, fields.getfirst(field))
413     info_dict['result'] = result
414     return templates.info(searchList=[info_dict])
415     
416
417 def helpHandler(user, fields):
418     """Handler for help messages."""
419     simple = fields.getfirst('simple')
420     subjects = fields.getlist('subject')
421     
422     help_mapping = dict(paravm_console="""
423 ParaVM machines do not support console access over VNC.  To access
424 these machines, you either need to boot with a liveCD and ssh in or
425 hope that the sipb-xen maintainers add support for serial consoles.""",
426                         hvm_paravm="""
427 HVM machines use the virtualization features of the processor, while
428 ParaVM machines use Xen's emulation of virtualization features.  You
429 want an HVM virtualized machine.""",
430                         cpu_weight="""
431 Don't ask us!  We're as mystified as you are.""",
432                         owner="""
433 The owner field is used to determine <a
434 href="help?subject=quotas">quotas</a>.  It must be the name of a
435 locker that you are an AFS administrator of.  In particular, you or an
436 AFS group you are a member of must have AFS rlidwka bits on the
437 locker.  You can check see who administers the LOCKER locker using the
438 command 'fs la /mit/LOCKER' on Athena.)  See also <a
439 href="help?subject=administrator">administrator</a>.""",
440                         administrator="""
441 The administrator field determines who can access the console and
442 power on and off the machine.  This can be either a user or a moira
443 group.""",
444                         quotas="""
445 Quotas are determined on a per-locker basis.  Each quota may have a
446 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
447 active machines."""
448                    )
449     
450     if not subjects:
451         subjects = sorted(help_mapping.keys())
452         
453     d = dict(user=user,
454              simple=simple,
455              subjects=subjects,
456              mapping=help_mapping)
457     
458     return templates.help(searchList=[d])
459     
460
461 def badOperation(u, e):
462     raise CodeError("Unknown operation")
463
464 def infoDict(user, machine):
465     status = controls.statusInfo(machine)
466     checkpoint.checkpoint('Getting status info')
467     has_vnc = hasVnc(status)
468     if status is None:
469         main_status = dict(name=machine.name,
470                            memory=str(machine.memory))
471         uptime = None
472         cputime = None
473     else:
474         main_status = dict(status[1:])
475         start_time = float(main_status.get('start_time', 0))
476         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
477         cpu_time_float = float(main_status.get('cpu_time', 0))
478         cputime = datetime.timedelta(seconds=int(cpu_time_float))
479     checkpoint.checkpoint('Status')
480     display_fields = """name uptime memory state cpu_weight on_reboot 
481      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
482     display_fields = [('name', 'Name'),
483                       ('owner', 'Owner'),
484                       ('administrator', 'Administrator'),
485                       ('contact', 'Contact'),
486                       ('type', 'Type'),
487                       'NIC_INFO',
488                       ('uptime', 'uptime'),
489                       ('cputime', 'CPU usage'),
490                       ('memory', 'RAM'),
491                       'DISK_INFO',
492                       ('state', 'state (xen format)'),
493                       ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
494                       ('on_reboot', 'Action on VM reboot'),
495                       ('on_poweroff', 'Action on VM poweroff'),
496                       ('on_crash', 'Action on VM crash'),
497                       ('on_xend_start', 'Action on Xen start'),
498                       ('on_xend_stop', 'Action on Xen stop'),
499                       ('bootloader', 'Bootloader options'),
500                       ]
501     fields = []
502     machine_info = {}
503     machine_info['name'] = machine.name
504     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
505     machine_info['owner'] = machine.owner
506     machine_info['administrator'] = machine.administrator
507     machine_info['contact'] = machine.contact
508
509     nic_fields = getNicInfo(machine_info, machine)
510     nic_point = display_fields.index('NIC_INFO')
511     display_fields = (display_fields[:nic_point] + nic_fields + 
512                       display_fields[nic_point+1:])
513
514     disk_fields = getDiskInfo(machine_info, machine)
515     disk_point = display_fields.index('DISK_INFO')
516     display_fields = (display_fields[:disk_point] + disk_fields + 
517                       display_fields[disk_point+1:])
518     
519     main_status['memory'] += ' MiB'
520     for field, disp in display_fields:
521         if field in ('uptime', 'cputime') and locals()[field] is not None:
522             fields.append((disp, locals()[field]))
523         elif field in machine_info:
524             fields.append((disp, machine_info[field]))
525         elif field in main_status:
526             fields.append((disp, main_status[field]))
527         else:
528             pass
529             #fields.append((disp, None))
530
531     checkpoint.checkpoint('Got fields')
532
533
534     max_mem = validation.maxMemory(user, machine, False)
535     checkpoint.checkpoint('Got mem')
536     max_disk = validation.maxDisk(user, machine)
537     defaults = Defaults()
538     for name in 'machine_id name administrator owner memory contact'.split():
539         setattr(defaults, name, getattr(machine, name))
540     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
541     checkpoint.checkpoint('Got defaults')
542     d = dict(user=user,
543              cdroms=CDROM.select(),
544              on=status is not None,
545              machine=machine,
546              defaults=defaults,
547              has_vnc=has_vnc,
548              uptime=str(uptime),
549              ram=machine.memory,
550              max_mem=max_mem,
551              max_disk=max_disk,
552              owner_help=helppopup("owner"),
553              fields = fields)
554     return d
555
556 def info(user, fields):
557     """Handler for info on a single VM."""
558     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
559     d = infoDict(user, machine)
560     checkpoint.checkpoint('Got infodict')
561     return templates.info(searchList=[d])
562
563 mapping = dict(list=listVms,
564                vnc=vnc,
565                command=command,
566                modify=modify,
567                info=info,
568                create=create,
569                help=helpHandler)
570
571 def printHeaders(headers):
572     for key, value in headers.iteritems():
573         print '%s: %s' % (key, value)
574     print
575
576
577 def getUser():
578     """Return the current user based on the SSL environment variables"""
579     if 'SSL_CLIENT_S_DN_Email' in os.environ:
580         username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
581         return username
582     else:
583         return 'moo'
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     #main(operation, u, fields)
645     import profile
646     profile.run('main(operation, u, fields)', 'log-'+operation)
647