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