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