source: trunk/packages/sipb-xen-www/code/main.py @ 438

Last change on this file since 438 was 438, checked in by ecprice, 16 years ago

Remove extraneous whitespace and add documentation

  • Property svn:executable set to *
File size: 22.7 KB
Line 
1#!/usr/bin/python
2"""Main CGI script for web interface"""
3
4import base64
5import cPickle
6import cgi
7import datetime
8import hmac
9import os
10import sha
11import simplejson
12import sys
13import time
14from StringIO import StringIO
15
16def 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
25def printError():
26    """Revert stderr to stdout, and print the contents of stderr"""
27    if isinstance(sys.stderr, StringIO):
28        print revertStandardError()
29
30if __name__ == '__main__':
31    import atexit
32    atexit.register(printError)
33    sys.stderr = StringIO()
34
35sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
36
37import templates
38from Cheetah.Template import Template
39from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess
40import validation
41from webcommon import InvalidInput, CodeError, g
42import controls
43
44class 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
57checkpoint = Checkpoint()
58
59
60def 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
66def 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
74Template.helppopup = staticmethod(helppopup)
75Template.err = None
76
77class 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
94class 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
111DEFAULT_HEADERS = {'Content-Type': 'text/html'}
112
113def 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
119def 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
126def 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
136def 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
168def 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
188def 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
226def 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
233def 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
279def 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
292def 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
314def 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
330def 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
359def 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
413def 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
433def 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="""
439ParaVM machines do not support local console access over VNC.  To
440access the serial console of these machines, you can SSH with Kerberos
441to sipb-xen-console.mit.edu, using the name of the machine as your
442username.""",
443                        hvm_paravm="""
444HVM machines use the virtualization features of the processor, while
445ParaVM machines use Xen's emulation of virtualization features.  You
446want an HVM virtualized machine.""",
447                        cpu_weight="""
448Don't ask us!  We're as mystified as you are.""",
449                        owner="""
450The owner field is used to determine <a
451href="help?subject=quotas">quotas</a>.  It must be the name of a
452locker that you are an AFS administrator of.  In particular, you or an
453AFS group you are a member of must have AFS rlidwka bits on the
454locker.  You can check who administers the LOCKER locker using the
455commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
456href="help?subject=administrator">administrator</a>.""",
457                        administrator="""
458The administrator field determines who can access the console and
459power on and off the machine.  This can be either a user or a moira
460group.""",
461                        quotas="""
462Quotas are determined on a per-locker basis.  Each locker may have a
463maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
464active machines.""",
465                        console="""
466<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
467setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
468your machine will run just fine, but the applet's display of the
469console 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
484def badOperation(u, e):
485    """Function called when accessing an unknown URI."""
486    raise CodeError("Unknown operation")
487
488def 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
581def 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
588mapping = dict(list=listVms,
589               vnc=vnc,
590               command=command,
591               modify=modify,
592               info=info,
593               create=create,
594               help=helpHandler)
595
596def 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
603def 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
608def 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
652if __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)
Note: See TracBrowser for help on using the repository browser.