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

Last change on this file since 382 was 340, checked in by price, 17 years ago

expose cloning autoinstaller in web interface

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