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