2 """Main CGI script for web interface"""
17 from StringIO import StringIO
20 class InvirtWeb(View):
25 def revertStandardError():
26 """Move stderr to stdout, and return the contents of the old stderr."""
28 if not isinstance(errio, StringIO):
30 sys.stderr = sys.stdout
35 """Revert stderr to stdout, and print the contents of stderr"""
36 if isinstance(sys.stderr, StringIO):
37 print revertStandardError()
39 if __name__ == '__main__':
41 atexit.register(printError)
44 from Cheetah.Template import Template
47 from webcommon import State
49 from getafsgroups import getAfsGroupMembers
50 from invirt import database
51 from invirt.database import Machine, CDROM, session, connect, MachineAccess, Type, Autoinstall
52 from invirt.config import structs as config
53 from invirt.common import InvalidInput, CodeError
56 if path.startswith('/'):
61 return path[:i], path[i:]
65 self.start_time = time.time()
68 def checkpoint(self, s):
69 self.checkpoints.append((s, time.time()))
72 return ('Timing info:\n%s\n' %
73 '\n'.join(['%s: %s' % (d, t - self.start_time) for
74 (d, t) in self.checkpoints]))
76 checkpoint = Checkpoint()
79 return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
82 """Return HTML code for a (?) link to a specified help topic"""
83 return ('<span class="helplink"><a href="help?' +
84 cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
85 +'" target="_blank" ' +
86 'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
88 def makeErrorPre(old, addition):
92 return old[:-6] + '\n----\n' + str(addition) + '</pre>'
94 return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
96 Template.database = database
97 Template.config = config
98 Template.helppopup = staticmethod(helppopup)
102 """Class to store a dictionary that will be converted to JSON"""
103 def __init__(self, **kws):
111 return simplejson.dumps(self.data)
113 def addError(self, text):
114 """Add stderr text to be displayed on the website."""
116 makeErrorPre(self.data.get('err'), text)
119 """Class to store default values for fields."""
128 def __init__(self, max_memory=None, max_disk=None, **kws):
129 if max_memory is not None:
130 self.memory = min(self.memory, max_memory)
131 if max_disk is not None:
132 self.disk = min(self.disk, max_disk)
134 setattr(self, key, kws[key])
138 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
140 def invalidInput(op, username, fields, err, emsg):
141 """Print an error page when an InvalidInput exception occurs"""
142 d = dict(op=op, user=username, err_field=err.err_field,
143 err_value=str(err.err_value), stderr=emsg,
144 errorMessage=str(err))
145 return templates.invalid(searchList=[d])
148 """Does the machine with a given status list support VNC?"""
152 if l[0] == 'device' and l[1][0] == 'vfb':
154 return 'location' in d
157 def parseCreate(username, state, fields):
158 kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()])
159 validate = validation.Validate(username, state, strict=True, **kws)
160 return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory,
161 disksize=validate.disksize, owner=validate.owner, machine_type=getattr(validate, 'vmtype', Defaults.type),
162 cdrom=getattr(validate, 'cdrom', None),
163 autoinstall=getattr(validate, 'autoinstall', None))
165 def create(username, state, path, fields):
166 """Handler for create requests."""
168 parsed_fields = parseCreate(username, state, fields)
169 machine = controls.createVm(username, state, **parsed_fields)
170 except InvalidInput, err:
174 state.clear() #Changed global state
175 d = getListDict(username, state)
178 for field in fields.keys():
179 setattr(d['defaults'], field, fields.getfirst(field))
181 d['new_machine'] = parsed_fields['name']
182 return templates.list(searchList=[d])
185 def getListDict(username, state):
186 """Gets the list of local variables used by list.tmpl."""
187 checkpoint.checkpoint('Starting')
188 machines = state.machines
189 checkpoint.checkpoint('Got my machines')
192 xmlist = state.xmlist
193 checkpoint.checkpoint('Got uptimes')
194 can_clone = 'ice3' not in state.xmlist_raw
200 m.uptime = xmlist[m]['uptime']
201 if xmlist[m]['console']:
206 has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
207 max_memory = validation.maxMemory(username, state)
208 max_disk = validation.maxDisk(username)
209 checkpoint.checkpoint('Got max mem/disk')
210 defaults = Defaults(max_memory=max_memory,
213 checkpoint.checkpoint('Got defaults')
214 def sortkey(machine):
215 return (machine.owner != username, machine.owner, machine.name)
216 machines = sorted(machines, key=sortkey)
217 d = dict(user=username,
218 cant_add_vm=validation.cantAddVm(username, state),
219 max_memory=max_memory,
227 def listVms(username, state, path, fields):
228 """Handler for list requests."""
229 checkpoint.checkpoint('Getting list dict')
230 d = getListDict(username, state)
231 checkpoint.checkpoint('Got list dict')
232 return templates.list(searchList=[d])
234 def vnc(username, state, path, fields):
237 Note that due to same-domain restrictions, the applet connects to
238 the webserver, which needs to forward those requests to the xen
239 server. The Xen server runs another proxy that (1) authenticates
240 and (2) finds the correct port for the VM.
242 You might want iptables like:
244 -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
245 --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
246 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
247 --dport 10003 -j SNAT --to-source 18.187.7.142
248 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
249 --dport 10003 -j ACCEPT
251 Remember to enable iptables!
252 echo 1 > /proc/sys/net/ipv4/ip_forward
254 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
256 token = controls.vnctoken(machine)
257 host = controls.listHost(machine)
259 port = 10003 + [h.hostname for h in config.hosts].index(host)
263 status = controls.statusInfo(machine)
264 has_vnc = hasVnc(status)
266 d = dict(user=username,
270 hostname=state.environ.get('SERVER_NAME', 'localhost'),
273 return templates.vnc(searchList=[d])
275 def getHostname(nic):
276 """Find the hostname associated with a NIC.
278 XXX this should be merged with the similar logic in DNS and DHCP.
281 hostname = nic.hostname
283 hostname = nic.machine.name
289 return hostname + '.' + config.dns.domains[0]
291 def getNicInfo(data_dict, machine):
292 """Helper function for info, get data on nics for a machine.
294 Modifies data_dict to include the relevant data, and returns a list
295 of (key, name) pairs to display "name: data_dict[key]" to the user.
297 data_dict['num_nics'] = len(machine.nics)
298 nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
299 ('nic%s_mac', 'NIC %s MAC Addr'),
300 ('nic%s_ip', 'NIC %s IP'),
303 for i in range(len(machine.nics)):
304 nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
305 data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
306 data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
307 data_dict['nic%s_ip' % i] = machine.nics[i].ip
308 if len(machine.nics) == 1:
309 nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
312 def getDiskInfo(data_dict, machine):
313 """Helper function for info, get data on disks for a machine.
315 Modifies data_dict to include the relevant data, and returns a list
316 of (key, name) pairs to display "name: data_dict[key]" to the user.
318 data_dict['num_disks'] = len(machine.disks)
319 disk_fields_template = [('%s_size', '%s size')]
321 for disk in machine.disks:
322 name = disk.guest_device_name
323 disk_fields.extend([(x % name, y % name) for x, y in
324 disk_fields_template])
325 data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
328 def command(username, state, path, fields):
329 """Handler for running commands like boot and delete on a VM."""
330 back = fields.getfirst('back')
332 d = controls.commandResult(username, state, fields)
333 if d['command'] == 'Delete VM':
335 except InvalidInput, err:
338 print >> sys.stderr, err
343 return templates.command(searchList=[d])
345 state.clear() #Changed global state
346 d = getListDict(username, state)
348 return templates.list(searchList=[d])
350 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
351 return ({'Status': '303 See Other',
352 'Location': 'info?machine_id=%d' % machine.machine_id},
353 "You shouldn't see this message.")
355 raise InvalidInput('back', back, 'Not a known back page.')
357 def modifyDict(username, state, fields):
358 """Modify a machine as specified by CGI arguments.
360 Return a list of local variables for modify.tmpl.
365 kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name description memory vmtype disksize'.split()])
366 validate = validation.Validate(username, state, **kws)
367 machine = validate.machine
368 oldname = machine.name
370 if hasattr(validate, 'memory'):
371 machine.memory = validate.memory
373 if hasattr(validate, 'vmtype'):
374 machine.type = validate.vmtype
376 if hasattr(validate, 'disksize'):
377 disksize = validate.disksize
378 disk = machine.disks[0]
379 if disk.size != disksize:
380 olddisk[disk.guest_device_name] = disksize
382 session.save_or_update(disk)
385 if hasattr(validate, 'owner') and validate.owner != machine.owner:
386 machine.owner = validate.owner
388 if hasattr(validate, 'name'):
389 machine.name = validate.name
390 for n in machine.nics:
391 if n.hostname == oldname:
392 n.hostname = validate.name
393 if hasattr(validate, 'description'):
394 machine.description = validate.description
395 if hasattr(validate, 'admin') and validate.admin != machine.administrator:
396 machine.administrator = validate.admin
398 if hasattr(validate, 'contact'):
399 machine.contact = validate.contact
401 session.save_or_update(machine)
403 cache_acls.refreshMachine(machine)
408 for diskname in olddisk:
409 controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
410 if hasattr(validate, 'name'):
411 controls.renameMachine(machine, oldname, validate.name)
412 return dict(user=username,
416 def modify(username, state, path, fields):
417 """Handler for modifying attributes of a machine."""
419 modify_dict = modifyDict(username, state, fields)
420 except InvalidInput, err:
422 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
424 machine = modify_dict['machine']
427 info_dict = infoDict(username, state, machine)
428 info_dict['err'] = err
430 for field in fields.keys():
431 setattr(info_dict['defaults'], field, fields.getfirst(field))
432 info_dict['result'] = result
433 return templates.info(searchList=[info_dict])
436 def helpHandler(username, state, path, fields):
437 """Handler for help messages."""
438 simple = fields.getfirst('simple')
439 subjects = fields.getlist('subject')
443 The autoinstaller builds a minimal Debian or Ubuntu system to run as a
444 ParaVM. You can access the resulting system by logging into the <a
445 href="help?simple=true&subject=ParaVM+Console">serial console server</a>
446 with your Kerberos tickets; there is no root password so sshd will
449 <p>Under the covers, the autoinstaller uses our own patched version of
450 xen-create-image, which is a tool based on debootstrap. If you log
451 into the serial console while the install is running, you can watch
454 'ParaVM Console': """
455 ParaVM machines do not support local console access over VNC. To
456 access the serial console of these machines, you can SSH with Kerberos
457 to %s, using the name of the machine as your
458 username.""" % config.console.hostname,
460 HVM machines use the virtualization features of the processor, while
461 ParaVM machines rely on a modified kernel to communicate directly with
462 the hypervisor. HVMs support boot CDs of any operating system, and
463 the VNC console applet. The three-minute autoinstaller produces
464 ParaVMs. ParaVMs typically are more efficient, and always support the
465 <a href="help?subject=ParaVM+Console">console server</a>.</p>
467 <p>More details are <a
468 href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
469 wiki</a>, including steps to prepare an HVM guest to boot as a ParaVM
470 (which you can skip by using the autoinstaller to begin with.)</p>
472 <p>We recommend using a ParaVM when possible and an HVM when necessary.
475 Don't ask us! We're as mystified as you are.""",
477 The owner field is used to determine <a
478 href="help?subject=Quotas">quotas</a>. It must be the name of a
479 locker that you are an AFS administrator of. In particular, you or an
480 AFS group you are a member of must have AFS rlidwka bits on the
481 locker. You can check who administers the LOCKER locker using the
482 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.) See also <a
483 href="help?subject=Administrator">administrator</a>.""",
485 The administrator field determines who can access the console and
486 power on and off the machine. This can be either a user or a moira
489 Quotas are determined on a per-locker basis. Each locker may have a
490 maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
493 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
494 setting <tt>fb=false</tt> to disable the framebuffer. If you don't,
495 your machine will run just fine, but the applet's display of the
496 console will suffer artifacts.
499 <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>
500 <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.
505 subjects = sorted(help_mapping.keys())
507 d = dict(user=username,
510 mapping=help_mapping)
512 return templates.help(searchList=[d])
515 def badOperation(u, s, p, e):
516 """Function called when accessing an unknown URI."""
517 return ({'Status': '404 Not Found'}, 'Invalid operation.')
519 def infoDict(username, state, machine):
520 """Get the variables used by info.tmpl."""
521 status = controls.statusInfo(machine)
522 checkpoint.checkpoint('Getting status info')
523 has_vnc = hasVnc(status)
525 main_status = dict(name=machine.name,
526 memory=str(machine.memory))
530 main_status = dict(status[1:])
531 main_status['host'] = controls.listHost(machine)
532 start_time = float(main_status.get('start_time', 0))
533 uptime = datetime.timedelta(seconds=int(time.time()-start_time))
534 cpu_time_float = float(main_status.get('cpu_time', 0))
535 cputime = datetime.timedelta(seconds=int(cpu_time_float))
536 checkpoint.checkpoint('Status')
537 display_fields = [('name', 'Name'),
538 ('description', 'Description'),
540 ('administrator', 'Administrator'),
541 ('contact', 'Contact'),
544 ('uptime', 'uptime'),
545 ('cputime', 'CPU usage'),
546 ('host', 'Hosted on'),
549 ('state', 'state (xen format)'),
550 ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
554 machine_info['name'] = machine.name
555 machine_info['description'] = machine.description
556 machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
557 machine_info['owner'] = machine.owner
558 machine_info['administrator'] = machine.administrator
559 machine_info['contact'] = machine.contact
561 nic_fields = getNicInfo(machine_info, machine)
562 nic_point = display_fields.index('NIC_INFO')
563 display_fields = (display_fields[:nic_point] + nic_fields +
564 display_fields[nic_point+1:])
566 disk_fields = getDiskInfo(machine_info, machine)
567 disk_point = display_fields.index('DISK_INFO')
568 display_fields = (display_fields[:disk_point] + disk_fields +
569 display_fields[disk_point+1:])
571 main_status['memory'] += ' MiB'
572 for field, disp in display_fields:
573 if field in ('uptime', 'cputime') and locals()[field] is not None:
574 fields.append((disp, locals()[field]))
575 elif field in machine_info:
576 fields.append((disp, machine_info[field]))
577 elif field in main_status:
578 fields.append((disp, main_status[field]))
581 #fields.append((disp, None))
583 checkpoint.checkpoint('Got fields')
586 max_mem = validation.maxMemory(machine.owner, state, machine, False)
587 checkpoint.checkpoint('Got mem')
588 max_disk = validation.maxDisk(machine.owner, machine)
589 defaults = Defaults()
590 for name in 'machine_id name description administrator owner memory contact'.split():
591 setattr(defaults, name, getattr(machine, name))
592 defaults.type = machine.type.type_id
593 defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
594 checkpoint.checkpoint('Got defaults')
595 d = dict(user=username,
596 on=status is not None,
604 owner_help=helppopup("Owner"),
608 def info(username, state, path, fields):
609 """Handler for info on a single VM."""
610 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
611 d = infoDict(username, state, machine)
612 checkpoint.checkpoint('Got infodict')
613 return templates.info(searchList=[d])
615 def unauthFront(_, _2, _3, fields):
616 """Information for unauth'd users."""
617 return templates.unauth(searchList=[{'simple' : True,
618 'hostname' : socket.getfqdn()}])
620 def admin(username, state, path, fields):
622 return ({'Status': '303 See Other',
623 'Location': 'admin/'},
624 "You shouldn't see this message.")
625 if not username in getAfsGroupMembers(config.adminacl, 'athena.mit.edu'):
626 raise InvalidInput('username', username,
627 'Not in admin group %s.' % config.adminacl)
628 newstate = State(username, isadmin=True)
629 newstate.environ = state.environ
630 return handler(username, newstate, path, fields)
632 def throwError(_, __, ___, ____):
633 """Throw an error, to test the error-tracing mechanisms."""
634 raise RuntimeError("test of the emergency broadcast system")
636 mapping = dict(list=listVms,
646 errortest=throwError)
648 def printHeaders(headers):
649 """Print a dictionary as HTTP headers."""
650 for key, value in headers.iteritems():
651 print '%s: %s' % (key, value)
654 def send_error_mail(subject, body):
657 to = config.web.errormail
663 """ % (to, config.web.hostname, subject, body)
664 p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
665 stdin=subprocess.PIPE)
670 def show_error(op, username, fields, err, emsg, traceback):
671 """Print an error page when an exception occurs"""
672 d = dict(op=op, user=username, fields=fields,
673 errorMessage=str(err), stderr=emsg, traceback=traceback)
674 details = templates.error_raw(searchList=[d])
675 exclude = config.web.errormail_exclude
676 if username not in exclude and '*' not in exclude:
677 send_error_mail('xvm error on %s for %s: %s' % (op, username, err),
679 d['details'] = details
680 return templates.error(searchList=[d])
682 def getUser(environ):
683 """Return the current user based on the SSL environment variables"""
684 user = environ.get('REMOTE_USER')
688 if environ.get('AUTH_TYPE') == 'Negotiate':
689 # Convert the krb5 principal into a krb4 username
690 if not user.endswith('@%s' % config.kerberos.realm):
693 return user.split('@')[0].replace('/', '.')
697 def handler(username, state, path, fields):
698 operation, path = pathSplit(path)
701 print 'Starting', operation
702 fun = mapping.get(operation, badOperation)
703 return fun(username, state, path, fields)
706 def __init__(self, environ, start_response):
707 self.environ = environ
708 self.start = start_response
710 self.username = getUser(environ)
711 self.state = State(self.username)
712 self.state.environ = environ
717 start_time = time.time()
718 database.clear_cache()
719 sys.stderr = StringIO()
720 fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
721 operation = self.environ.get('PATH_INFO', '')
723 self.start("301 Moved Permanently", [('Location', './')])
725 if self.username is None:
729 checkpoint.checkpoint('Before')
730 output = handler(self.username, self.state, operation, fields)
731 checkpoint.checkpoint('After')
733 headers = dict(DEFAULT_HEADERS)
734 if isinstance(output, tuple):
735 new_headers, output = output
736 headers.update(new_headers)
737 e = revertStandardError()
739 if hasattr(output, 'addError'):
742 # This only happens on redirects, so it'd be a pain to get
743 # the message to the user. Maybe in the response is useful.
744 output = output + '\n\nstderr:\n' + e
745 output_string = str(output)
746 checkpoint.checkpoint('output as a string')
747 except Exception, err:
748 if not fields.has_key('js'):
749 if isinstance(err, InvalidInput):
750 self.start('200 OK', [('Content-Type', 'text/html')])
751 e = revertStandardError()
752 yield str(invalidInput(operation, self.username, fields,
756 self.start('500 Internal Server Error',
757 [('Content-Type', 'text/html')])
758 e = revertStandardError()
759 s = show_error(operation, self.username, fields,
760 err, e, traceback.format_exc())
763 status = headers.setdefault('Status', '200 OK')
764 del headers['Status']
765 self.start(status, headers.items())
767 if fields.has_key('timedebug'):
768 yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
775 from flup.server.fcgi_fork import WSGIServer
776 WSGIServer(constructor()).run()
778 if __name__ == '__main__':