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