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

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

Support setting paravm/hvm for off, but already created, VMs.

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