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