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

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

Update cache for machines on their modification.

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