source: trunk/web/main.py @ 266

Last change on this file since 266 was 266, checked in by quentin, 16 years ago

Check quotas against the machine's owner and not the user committing the act

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