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