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