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

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

Cleaner HVM/paravm validation

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