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