2 """Main CGI script for web interface"""
17 from StringIO import StringIO
18 def revertStandardError():
19 """Move stderr to stdout, and return the contents of the old stderr."""
21 if not isinstance(errio, StringIO):
23 sys.stderr = sys.stdout
28 """Revert stderr to stdout, and print the contents of stderr"""
29 if isinstance(sys.stderr, StringIO):
30 print revertStandardError()
32 if __name__ == '__main__':
34 atexit.register(printError)
37 from Cheetah.Template import Template
40 from webcommon import State
42 from getafsgroups import getAfsGroupMembers
43 from invirt import database
44 from invirt.database import Machine, CDROM, session, connect, MachineAccess, Type, Autoinstall
45 from invirt.config import structs as config
46 from invirt.common import InvalidInput, CodeError
50 class InvirtWeb(View):
52 super(self.__class__,self).__init__()
54 self._cp_config['tools.require_login.on'] = True
57 @cherrypy.tools.mako(filename="/list.mako")
58 def list(self, username):
59 """Handler for list requests."""
60 checkpoint.checkpoint('Getting list dict')
61 d = getListDict(username, state)
62 checkpoint.checkpoint('Got list dict')
63 return templates.list(searchList=[d])
67 @cherrypy.tools.mako(filename="/helloworld.mako")
70 return "Hello world!\nYour request: "+repr(dir(cherrypy.request))
71 helloworld._cp_config['tools.require_login.on'] = False
74 if path.startswith('/'):
79 return path[:i], path[i:]
83 self.start_time = time.time()
86 def checkpoint(self, s):
87 self.checkpoints.append((s, time.time()))
90 return ('Timing info:\n%s\n' %
91 '\n'.join(['%s: %s' % (d, t - self.start_time) for
92 (d, t) in self.checkpoints]))
94 checkpoint = Checkpoint()
97 return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
100 """Return HTML code for a (?) link to a specified help topic"""
101 return ('<span class="helplink"><a href="help?' +
102 cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
103 +'" target="_blank" ' +
104 'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
106 def makeErrorPre(old, addition):
110 return old[:-6] + '\n----\n' + str(addition) + '</pre>'
112 return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
114 Template.database = database
115 Template.config = config
116 Template.helppopup = staticmethod(helppopup)
120 """Class to store a dictionary that will be converted to JSON"""
121 def __init__(self, **kws):
129 return simplejson.dumps(self.data)
131 def addError(self, text):
132 """Add stderr text to be displayed on the website."""
134 makeErrorPre(self.data.get('err'), text)
137 """Class to store default values for fields."""
146 def __init__(self, max_memory=None, max_disk=None, **kws):
147 if max_memory is not None:
148 self.memory = min(self.memory, max_memory)
149 if max_disk is not None:
150 self.disk = min(self.disk, max_disk)
152 setattr(self, key, kws[key])
156 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
158 def invalidInput(op, username, fields, err, emsg):
159 """Print an error page when an InvalidInput exception occurs"""
160 d = dict(op=op, user=username, err_field=err.err_field,
161 err_value=str(err.err_value), stderr=emsg,
162 errorMessage=str(err))
163 return templates.invalid(searchList=[d])
166 """Does the machine with a given status list support VNC?"""
170 if l[0] == 'device' and l[1][0] == 'vfb':
172 return 'location' in d
175 def parseCreate(username, state, fields):
176 kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()])
177 validate = validation.Validate(username, state, strict=True, **kws)
178 return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory,
179 disksize=validate.disksize, owner=validate.owner, machine_type=getattr(validate, 'vmtype', Defaults.type),
180 cdrom=getattr(validate, 'cdrom', None),
181 autoinstall=getattr(validate, 'autoinstall', None))
183 def create(username, state, path, fields):
184 """Handler for create requests."""
186 parsed_fields = parseCreate(username, state, fields)
187 machine = controls.createVm(username, state, **parsed_fields)
188 except InvalidInput, err:
192 state.clear() #Changed global state
193 d = getListDict(username, state)
196 for field in fields.keys():
197 setattr(d['defaults'], field, fields.getfirst(field))
199 d['new_machine'] = parsed_fields['name']
200 return templates.list(searchList=[d])
203 def getListDict(username, state):
204 """Gets the list of local variables used by list.tmpl."""
205 checkpoint.checkpoint('Starting')
206 machines = state.machines
207 checkpoint.checkpoint('Got my machines')
210 xmlist = state.xmlist
211 checkpoint.checkpoint('Got uptimes')
212 can_clone = 'ice3' not in state.xmlist_raw
218 m.uptime = xmlist[m]['uptime']
219 if xmlist[m]['console']:
224 has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
225 max_memory = validation.maxMemory(username, state)
226 max_disk = validation.maxDisk(username)
227 checkpoint.checkpoint('Got max mem/disk')
228 defaults = Defaults(max_memory=max_memory,
231 checkpoint.checkpoint('Got defaults')
232 def sortkey(machine):
233 return (machine.owner != username, machine.owner, machine.name)
234 machines = sorted(machines, key=sortkey)
235 d = dict(user=username,
236 cant_add_vm=validation.cantAddVm(username, state),
237 max_memory=max_memory,
245 def vnc(username, state, path, fields):
248 Note that due to same-domain restrictions, the applet connects to
249 the webserver, which needs to forward those requests to the xen
250 server. The Xen server runs another proxy that (1) authenticates
251 and (2) finds the correct port for the VM.
253 You might want iptables like:
255 -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
256 --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
257 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
258 --dport 10003 -j SNAT --to-source 18.187.7.142
259 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
260 --dport 10003 -j ACCEPT
262 Remember to enable iptables!
263 echo 1 > /proc/sys/net/ipv4/ip_forward
265 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
267 token = controls.vnctoken(machine)
268 host = controls.listHost(machine)
270 port = 10003 + [h.hostname for h in config.hosts].index(host)
274 status = controls.statusInfo(machine)
275 has_vnc = hasVnc(status)
277 d = dict(user=username,
281 hostname=state.environ.get('SERVER_NAME', 'localhost'),
284 return templates.vnc(searchList=[d])
286 def getHostname(nic):
287 """Find the hostname associated with a NIC.
289 XXX this should be merged with the similar logic in DNS and DHCP.
292 hostname = nic.hostname
294 hostname = nic.machine.name
300 return hostname + '.' + config.dns.domains[0]
302 def getNicInfo(data_dict, machine):
303 """Helper function for info, get data on nics for a machine.
305 Modifies data_dict to include the relevant data, and returns a list
306 of (key, name) pairs to display "name: data_dict[key]" to the user.
308 data_dict['num_nics'] = len(machine.nics)
309 nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
310 ('nic%s_mac', 'NIC %s MAC Addr'),
311 ('nic%s_ip', 'NIC %s IP'),
314 for i in range(len(machine.nics)):
315 nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
316 data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
317 data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
318 data_dict['nic%s_ip' % i] = machine.nics[i].ip
319 if len(machine.nics) == 1:
320 nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
323 def getDiskInfo(data_dict, machine):
324 """Helper function for info, get data on disks for a machine.
326 Modifies data_dict to include the relevant data, and returns a list
327 of (key, name) pairs to display "name: data_dict[key]" to the user.
329 data_dict['num_disks'] = len(machine.disks)
330 disk_fields_template = [('%s_size', '%s size')]
332 for disk in machine.disks:
333 name = disk.guest_device_name
334 disk_fields.extend([(x % name, y % name) for x, y in
335 disk_fields_template])
336 data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
339 def command(username, state, path, fields):
340 """Handler for running commands like boot and delete on a VM."""
341 back = fields.getfirst('back')
343 d = controls.commandResult(username, state, fields)
344 if d['command'] == 'Delete VM':
346 except InvalidInput, err:
349 print >> sys.stderr, err
354 return templates.command(searchList=[d])
356 state.clear() #Changed global state
357 d = getListDict(username, state)
359 return templates.list(searchList=[d])
361 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
362 return ({'Status': '303 See Other',
363 'Location': 'info?machine_id=%d' % machine.machine_id},
364 "You shouldn't see this message.")
366 raise InvalidInput('back', back, 'Not a known back page.')
368 def modifyDict(username, state, fields):
369 """Modify a machine as specified by CGI arguments.
371 Return a list of local variables for modify.tmpl.
376 kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name description memory vmtype disksize'.split()])
377 validate = validation.Validate(username, state, **kws)
378 machine = validate.machine
379 oldname = machine.name
381 if hasattr(validate, 'memory'):
382 machine.memory = validate.memory
384 if hasattr(validate, 'vmtype'):
385 machine.type = validate.vmtype
387 if hasattr(validate, 'disksize'):
388 disksize = validate.disksize
389 disk = machine.disks[0]
390 if disk.size != disksize:
391 olddisk[disk.guest_device_name] = disksize
393 session.save_or_update(disk)
396 if hasattr(validate, 'owner') and validate.owner != machine.owner:
397 machine.owner = validate.owner
399 if hasattr(validate, 'name'):
400 machine.name = validate.name
401 for n in machine.nics:
402 if n.hostname == oldname:
403 n.hostname = validate.name
404 if hasattr(validate, 'description'):
405 machine.description = validate.description
406 if hasattr(validate, 'admin') and validate.admin != machine.administrator:
407 machine.administrator = validate.admin
409 if hasattr(validate, 'contact'):
410 machine.contact = validate.contact
412 session.save_or_update(machine)
414 cache_acls.refreshMachine(machine)
419 for diskname in olddisk:
420 controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
421 if hasattr(validate, 'name'):
422 controls.renameMachine(machine, oldname, validate.name)
423 return dict(user=username,
427 def modify(username, state, path, fields):
428 """Handler for modifying attributes of a machine."""
430 modify_dict = modifyDict(username, state, fields)
431 except InvalidInput, err:
433 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
435 machine = modify_dict['machine']
438 info_dict = infoDict(username, state, machine)
439 info_dict['err'] = err
441 for field in fields.keys():
442 setattr(info_dict['defaults'], field, fields.getfirst(field))
443 info_dict['result'] = result
444 return templates.info(searchList=[info_dict])
447 def helpHandler(username, state, path, fields):
448 """Handler for help messages."""
449 simple = fields.getfirst('simple')
450 subjects = fields.getlist('subject')
454 The autoinstaller builds a minimal Debian or Ubuntu system to run as a
455 ParaVM. You can access the resulting system by logging into the <a
456 href="help?simple=true&subject=ParaVM+Console">serial console server</a>
457 with your Kerberos tickets; there is no root password so sshd will
460 <p>Under the covers, the autoinstaller uses our own patched version of
461 xen-create-image, which is a tool based on debootstrap. If you log
462 into the serial console while the install is running, you can watch
465 'ParaVM Console': """
466 ParaVM machines do not support local console access over VNC. To
467 access the serial console of these machines, you can SSH with Kerberos
468 to %s, using the name of the machine as your
469 username.""" % config.console.hostname,
471 HVM machines use the virtualization features of the processor, while
472 ParaVM machines rely on a modified kernel to communicate directly with
473 the hypervisor. HVMs support boot CDs of any operating system, and
474 the VNC console applet. The three-minute autoinstaller produces
475 ParaVMs. ParaVMs typically are more efficient, and always support the
476 <a href="help?subject=ParaVM+Console">console server</a>.</p>
478 <p>More details are <a
479 href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
480 wiki</a>, including steps to prepare an HVM guest to boot as a ParaVM
481 (which you can skip by using the autoinstaller to begin with.)</p>
483 <p>We recommend using a ParaVM when possible and an HVM when necessary.
486 Don't ask us! We're as mystified as you are.""",
488 The owner field is used to determine <a
489 href="help?subject=Quotas">quotas</a>. It must be the name of a
490 locker that you are an AFS administrator of. In particular, you or an
491 AFS group you are a member of must have AFS rlidwka bits on the
492 locker. You can check who administers the LOCKER locker using the
493 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.) See also <a
494 href="help?subject=Administrator">administrator</a>.""",
496 The administrator field determines who can access the console and
497 power on and off the machine. This can be either a user or a moira
500 Quotas are determined on a per-locker basis. Each locker may have a
501 maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
504 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
505 setting <tt>fb=false</tt> to disable the framebuffer. If you don't,
506 your machine will run just fine, but the applet's display of the
507 console will suffer artifacts.
510 <strong>Windows Vista:</strong> The Vista image is licensed for all MIT students and will automatically activate off the network; see <a href="/static/msca-email.txt">the licensing confirmation e-mail</a> for details. The installer requires 512 MiB RAM and at least 7.5 GiB disk space (15 GiB or more recommended).<br>
511 <strong>Windows XP:</strong> This is the volume license CD image. You will need your own volume license key to complete the install. We do not have these available for the general MIT community; ask your department if they have one.
516 subjects = sorted(help_mapping.keys())
518 d = dict(user=username,
521 mapping=help_mapping)
523 return templates.help(searchList=[d])
526 def badOperation(u, s, p, e):
527 """Function called when accessing an unknown URI."""
528 return ({'Status': '404 Not Found'}, 'Invalid operation.')
530 def infoDict(username, state, machine):
531 """Get the variables used by info.tmpl."""
532 status = controls.statusInfo(machine)
533 checkpoint.checkpoint('Getting status info')
534 has_vnc = hasVnc(status)
536 main_status = dict(name=machine.name,
537 memory=str(machine.memory))
541 main_status = dict(status[1:])
542 main_status['host'] = controls.listHost(machine)
543 start_time = float(main_status.get('start_time', 0))
544 uptime = datetime.timedelta(seconds=int(time.time()-start_time))
545 cpu_time_float = float(main_status.get('cpu_time', 0))
546 cputime = datetime.timedelta(seconds=int(cpu_time_float))
547 checkpoint.checkpoint('Status')
548 display_fields = [('name', 'Name'),
549 ('description', 'Description'),
551 ('administrator', 'Administrator'),
552 ('contact', 'Contact'),
555 ('uptime', 'uptime'),
556 ('cputime', 'CPU usage'),
557 ('host', 'Hosted on'),
560 ('state', 'state (xen format)'),
561 ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
565 machine_info['name'] = machine.name
566 machine_info['description'] = machine.description
567 machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
568 machine_info['owner'] = machine.owner
569 machine_info['administrator'] = machine.administrator
570 machine_info['contact'] = machine.contact
572 nic_fields = getNicInfo(machine_info, machine)
573 nic_point = display_fields.index('NIC_INFO')
574 display_fields = (display_fields[:nic_point] + nic_fields +
575 display_fields[nic_point+1:])
577 disk_fields = getDiskInfo(machine_info, machine)
578 disk_point = display_fields.index('DISK_INFO')
579 display_fields = (display_fields[:disk_point] + disk_fields +
580 display_fields[disk_point+1:])
582 main_status['memory'] += ' MiB'
583 for field, disp in display_fields:
584 if field in ('uptime', 'cputime') and locals()[field] is not None:
585 fields.append((disp, locals()[field]))
586 elif field in machine_info:
587 fields.append((disp, machine_info[field]))
588 elif field in main_status:
589 fields.append((disp, main_status[field]))
592 #fields.append((disp, None))
594 checkpoint.checkpoint('Got fields')
597 max_mem = validation.maxMemory(machine.owner, state, machine, False)
598 checkpoint.checkpoint('Got mem')
599 max_disk = validation.maxDisk(machine.owner, machine)
600 defaults = Defaults()
601 for name in 'machine_id name description administrator owner memory contact'.split():
602 setattr(defaults, name, getattr(machine, name))
603 defaults.type = machine.type.type_id
604 defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
605 checkpoint.checkpoint('Got defaults')
606 d = dict(user=username,
607 on=status is not None,
615 owner_help=helppopup("Owner"),
619 def info(username, state, path, fields):
620 """Handler for info on a single VM."""
621 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
622 d = infoDict(username, state, machine)
623 checkpoint.checkpoint('Got infodict')
624 return templates.info(searchList=[d])
626 def unauthFront(_, _2, _3, fields):
627 """Information for unauth'd users."""
628 return templates.unauth(searchList=[{'simple' : True,
629 'hostname' : socket.getfqdn()}])
631 def admin(username, state, path, fields):
633 return ({'Status': '303 See Other',
634 'Location': 'admin/'},
635 "You shouldn't see this message.")
636 if not username in getAfsGroupMembers(config.adminacl, 'athena.mit.edu'):
637 raise InvalidInput('username', username,
638 'Not in admin group %s.' % config.adminacl)
639 newstate = State(username, isadmin=True)
640 newstate.environ = state.environ
641 return handler(username, newstate, path, fields)
643 def throwError(_, __, ___, ____):
644 """Throw an error, to test the error-tracing mechanisms."""
645 raise RuntimeError("test of the emergency broadcast system")
647 mapping = dict(#list=listVms,
657 errortest=throwError)
659 def printHeaders(headers):
660 """Print a dictionary as HTTP headers."""
661 for key, value in headers.iteritems():
662 print '%s: %s' % (key, value)
665 def send_error_mail(subject, body):
668 to = config.web.errormail
674 """ % (to, config.web.hostname, subject, body)
675 p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
676 stdin=subprocess.PIPE)
681 def show_error(op, username, fields, err, emsg, traceback):
682 """Print an error page when an exception occurs"""
683 d = dict(op=op, user=username, fields=fields,
684 errorMessage=str(err), stderr=emsg, traceback=traceback)
685 details = templates.error_raw(searchList=[d])
686 exclude = config.web.errormail_exclude
687 if username not in exclude and '*' not in exclude:
688 send_error_mail('xvm error on %s for %s: %s' % (op, username, err),
690 d['details'] = details
691 return templates.error(searchList=[d])
693 def getUser(environ):
694 """Return the current user based on the SSL environment variables"""
695 user = environ.get('REMOTE_USER')
699 if environ.get('AUTH_TYPE') == 'Negotiate':
700 # Convert the krb5 principal into a krb4 username
701 if not user.endswith('@%s' % config.kerberos.realm):
704 return user.split('@')[0].replace('/', '.')
708 def handler(username, state, path, fields):
709 operation, path = pathSplit(path)
712 print 'Starting', operation
713 fun = mapping.get(operation, badOperation)
714 return fun(username, state, path, fields)
717 def __init__(self, environ, start_response):
718 self.environ = environ
719 self.start = start_response
721 self.username = getUser(environ)
722 self.state = State(self.username)
723 self.state.environ = environ
728 start_time = time.time()
729 database.clear_cache()
730 sys.stderr = StringIO()
731 fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
732 operation = self.environ.get('PATH_INFO', '')
734 self.start("301 Moved Permanently", [('Location', './')])
736 if self.username is None:
740 checkpoint.checkpoint('Before')
741 output = handler(self.username, self.state, operation, fields)
742 checkpoint.checkpoint('After')
744 headers = dict(DEFAULT_HEADERS)
745 if isinstance(output, tuple):
746 new_headers, output = output
747 headers.update(new_headers)
748 e = revertStandardError()
750 if hasattr(output, 'addError'):
753 # This only happens on redirects, so it'd be a pain to get
754 # the message to the user. Maybe in the response is useful.
755 output = output + '\n\nstderr:\n' + e
756 output_string = str(output)
757 checkpoint.checkpoint('output as a string')
758 except Exception, err:
759 if not fields.has_key('js'):
760 if isinstance(err, InvalidInput):
761 self.start('200 OK', [('Content-Type', 'text/html')])
762 e = revertStandardError()
763 yield str(invalidInput(operation, self.username, fields,
767 self.start('500 Internal Server Error',
768 [('Content-Type', 'text/html')])
769 e = revertStandardError()
770 s = show_error(operation, self.username, fields,
771 err, e, traceback.format_exc())
774 status = headers.setdefault('Status', '200 OK')
775 del headers['Status']
776 self.start(status, headers.items())
778 if fields.has_key('timedebug'):
779 yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
786 from flup.server.fcgi_fork import WSGIServer
787 WSGIServer(constructor()).run()
789 if __name__ == '__main__':