keep help page at a readable width
[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, Autoinstall
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     autoinstall = ''
102     name = ''
103     def __init__(self, max_memory=None, max_disk=None, **kws):
104         self.type = Type.get('linux-hvm')
105         if max_memory is not None:
106             self.memory = min(self.memory, max_memory)
107         if max_disk is not None:
108             self.max_disk = min(self.disk, max_disk)
109         for key in kws:
110             setattr(self, key, kws[key])
111
112
113
114 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
115
116 def error(op, user, fields, err, emsg):
117     """Print an error page when a CodeError occurs"""
118     d = dict(op=op, user=user, errorMessage=str(err),
119              stderr=emsg)
120     return templates.error(searchList=[d])
121
122 def invalidInput(op, user, fields, err, emsg):
123     """Print an error page when an InvalidInput exception occurs"""
124     d = dict(op=op, user=user, err_field=err.err_field,
125              err_value=str(err.err_value), stderr=emsg,
126              errorMessage=str(err))
127     return templates.invalid(searchList=[d])
128
129 def hasVnc(status):
130     """Does the machine with a given status list support VNC?"""
131     if status is None:
132         return False
133     for l in status:
134         if l[0] == 'device' and l[1][0] == 'vfb':
135             d = dict(l[1][1:])
136             return 'location' in d
137     return False
138
139 def parseCreate(user, fields):
140     name = fields.getfirst('name')
141     if not validation.validMachineName(name):
142         raise InvalidInput('name', name, 'You must provide a machine name.  Max 22 chars, alnum plus \'-\' and \'_\'.')
143     name = name.lower()
144
145     if Machine.get_by(name=name):
146         raise InvalidInput('name', name,
147                            "Name already exists.")
148
149     owner = validation.testOwner(user, fields.getfirst('owner'))
150
151     memory = fields.getfirst('memory')
152     memory = validation.validMemory(owner, memory, on=True)
153
154     disk_size = fields.getfirst('disk')
155     disk_size = validation.validDisk(owner, disk_size)
156
157     vm_type = fields.getfirst('vmtype')
158     vm_type = validation.validVmType(vm_type)
159
160     cdrom = fields.getfirst('cdrom')
161     if cdrom is not None and not CDROM.get(cdrom):
162         raise CodeError("Invalid cdrom type '%s'" % cdrom)
163
164     clone_from = fields.getfirst('clone_from')
165     if clone_from and clone_from != 'ice3':
166         raise CodeError("Invalid clone image '%s'" % clone_from)
167
168     return dict(contact=user, name=name, memory=memory, disk_size=disk_size,
169                 owner=owner, machine_type=vm_type, cdrom=cdrom, clone_from=clone_from)
170
171 def create(user, fields):
172     """Handler for create requests."""
173     try:
174         parsed_fields = parseCreate(user, fields)
175         machine = controls.createVm(**parsed_fields)
176     except InvalidInput, err:
177         pass
178     else:
179         err = None
180     g.clear() #Changed global state
181     d = getListDict(user)
182     d['err'] = err
183     if err:
184         for field in fields.keys():
185             setattr(d['defaults'], field, fields.getfirst(field))
186     else:
187         d['new_machine'] = parsed_fields['name']
188     return templates.list(searchList=[d])
189
190
191 def getListDict(user):
192     """Gets the list of local variables used by list.tmpl."""
193     machines = g.machines
194     checkpoint.checkpoint('Got my machines')
195     on = {}
196     has_vnc = {}
197     on = g.uptimes
198     checkpoint.checkpoint('Got uptimes')
199     for m in machines:
200         m.uptime = g.uptimes.get(m)
201         if not on[m]:
202             has_vnc[m] = 'Off'
203         elif m.type.hvm:
204             has_vnc[m] = True
205         else:
206             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
207     max_memory = validation.maxMemory(user)
208     max_disk = validation.maxDisk(user)
209     checkpoint.checkpoint('Got max mem/disk')
210     defaults = Defaults(max_memory=max_memory,
211                         max_disk=max_disk,
212                         owner=user,
213                         cdrom='gutsy-i386')
214     checkpoint.checkpoint('Got defaults')
215     def sortkey(machine):
216         return (machine.owner != user, machine.owner, machine.name)
217     machines = sorted(machines, key=sortkey)
218     d = dict(user=user,
219              cant_add_vm=validation.cantAddVm(user),
220              max_memory=max_memory,
221              max_disk=max_disk,
222              defaults=defaults,
223              machines=machines,
224              has_vnc=has_vnc,
225              uptimes=g.uptimes)
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              on=status is not None,
575              machine=machine,
576              defaults=defaults,
577              has_vnc=has_vnc,
578              uptime=str(uptime),
579              ram=machine.memory,
580              max_mem=max_mem,
581              max_disk=max_disk,
582              owner_help=helppopup("owner"),
583              fields = fields)
584     return d
585
586 def info(user, fields):
587     """Handler for info on a single VM."""
588     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
589     d = infoDict(user, machine)
590     checkpoint.checkpoint('Got infodict')
591     return templates.info(searchList=[d])
592
593 mapping = dict(list=listVms,
594                vnc=vnc,
595                command=command,
596                modify=modify,
597                info=info,
598                create=create,
599                help=helpHandler)
600
601 def printHeaders(headers):
602     """Print a dictionary as HTTP headers."""
603     for key, value in headers.iteritems():
604         print '%s: %s' % (key, value)
605     print
606
607
608 def getUser():
609     """Return the current user based on the SSL environment variables"""
610     username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
611     return username
612
613 def main(operation, user, fields):
614     start_time = time.time()
615     fun = mapping.get(operation, badOperation)
616
617     if fun not in (helpHandler, ):
618         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
619     try:
620         checkpoint.checkpoint('Before')
621         output = fun(u, fields)
622         checkpoint.checkpoint('After')
623
624         headers = dict(DEFAULT_HEADERS)
625         if isinstance(output, tuple):
626             new_headers, output = output
627             headers.update(new_headers)
628         e = revertStandardError()
629         if e:
630             output.addError(e)
631         printHeaders(headers)
632         output_string =  str(output)
633         checkpoint.checkpoint('output as a string')
634         print output_string
635         print '<!-- <pre>%s</pre> -->' % checkpoint
636     except Exception, err:
637         if not fields.has_key('js'):
638             if isinstance(err, CodeError):
639                 print 'Content-Type: text/html\n'
640                 e = revertStandardError()
641                 print error(operation, u, fields, err, e)
642                 sys.exit(1)
643             if isinstance(err, InvalidInput):
644                 print 'Content-Type: text/html\n'
645                 e = revertStandardError()
646                 print invalidInput(operation, u, fields, err, e)
647                 sys.exit(1)
648         print 'Content-Type: text/plain\n'
649         print 'Uh-oh!  We experienced an error.'
650         print 'Please email sipb-xen@mit.edu with the contents of this page.'
651         print '----'
652         e = revertStandardError()
653         print e
654         print '----'
655         raise
656
657 if __name__ == '__main__':
658     fields = cgi.FieldStorage()
659     u = getUser()
660     g.user = u
661     operation = os.environ.get('PATH_INFO', '')
662     if not operation:
663         print "Status: 301 Moved Permanently"
664         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
665         sys.exit(0)
666
667     if operation.startswith('/'):
668         operation = operation[1:]
669     if not operation:
670         operation = 'list'
671
672     if os.getenv("SIPB_XEN_PROFILE"):
673         import profile
674         profile.run('main(operation, u, fields)', 'log-'+operation)
675     else:
676         main(operation, u, fields)