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