source: trunk/web/templates/main.py @ 209

Last change on this file since 209 was 209, checked in by ecprice, 17 years ago

Split main.py in four.

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