2 """Main CGI script for web interface"""
14 from StringIO import StringIO
16 def revertStandardError():
17 """Move stderr to stdout, and return the contents of the old stderr."""
19 if not isinstance(errio, StringIO):
21 sys.stderr = sys.stdout
26 """Revert stderr to stdout, and print the contents of stderr"""
27 if isinstance(sys.stderr, StringIO):
28 print revertStandardError()
30 if __name__ == '__main__':
32 atexit.register(printError)
35 from Cheetah.Template import Template
36 import sipb_xen_database
37 from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
40 from webcommon import InvalidInput, CodeError, State
42 from getafsgroups import getAfsGroupMembers
45 if path.startswith('/'):
50 return path[:i], path[i:]
54 self.start_time = time.time()
57 def checkpoint(self, s):
58 self.checkpoints.append((s, time.time()))
61 return ('Timing info:\n%s\n' %
62 '\n'.join(['%s: %s' % (d, t - self.start_time) for
63 (d, t) in self.checkpoints]))
65 checkpoint = Checkpoint()
68 return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
71 """Return HTML code for a (?) link to a specified help topic"""
72 return ('<span class="helplink"><a href="help?' +
73 cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
74 +'" target="_blank" ' +
75 'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
77 def makeErrorPre(old, addition):
81 return old[:-6] + '\n----\n' + str(addition) + '</pre>'
83 return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
85 Template.sipb_xen_database = sipb_xen_database
86 Template.helppopup = staticmethod(helppopup)
90 """Class to store a dictionary that will be converted to JSON"""
91 def __init__(self, **kws):
99 return simplejson.dumps(self.data)
101 def addError(self, text):
102 """Add stderr text to be displayed on the website."""
104 makeErrorPre(self.data.get('err'), text)
107 """Class to store default values for fields."""
116 def __init__(self, max_memory=None, max_disk=None, **kws):
117 if max_memory is not None:
118 self.memory = min(self.memory, max_memory)
119 if max_disk is not None:
120 self.max_disk = min(self.disk, max_disk)
122 setattr(self, key, kws[key])
126 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
128 def invalidInput(op, username, fields, err, emsg):
129 """Print an error page when an InvalidInput exception occurs"""
130 d = dict(op=op, user=username, err_field=err.err_field,
131 err_value=str(err.err_value), stderr=emsg,
132 errorMessage=str(err))
133 return templates.invalid(searchList=[d])
136 """Does the machine with a given status list support VNC?"""
140 if l[0] == 'device' and l[1][0] == 'vfb':
142 return 'location' in d
145 def parseCreate(username, state, fields):
146 kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()])
147 validate = validation.Validate(username, state, strict=True, **kws)
148 return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory,
149 disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
150 cdrom=getattr(validate, 'cdrom', None),
151 autoinstall=getattr(validate, 'autoinstall', None))
153 def create(username, state, path, fields):
154 """Handler for create requests."""
156 parsed_fields = parseCreate(username, state, fields)
157 machine = controls.createVm(username, state, **parsed_fields)
158 except InvalidInput, err:
162 state.clear() #Changed global state
163 d = getListDict(username, state)
166 for field in fields.keys():
167 setattr(d['defaults'], field, fields.getfirst(field))
169 d['new_machine'] = parsed_fields['name']
170 return templates.list(searchList=[d])
173 def getListDict(username, state):
174 """Gets the list of local variables used by list.tmpl."""
175 checkpoint.checkpoint('Starting')
176 machines = state.machines
177 checkpoint.checkpoint('Got my machines')
180 xmlist = state.xmlist
181 checkpoint.checkpoint('Got uptimes')
182 can_clone = 'ice3' not in state.xmlist_raw
188 m.uptime = xmlist[m]['uptime']
189 if xmlist[m]['console']:
194 has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
195 max_memory = validation.maxMemory(username, state)
196 max_disk = validation.maxDisk(username)
197 checkpoint.checkpoint('Got max mem/disk')
198 defaults = Defaults(max_memory=max_memory,
202 checkpoint.checkpoint('Got defaults')
203 def sortkey(machine):
204 return (machine.owner != username, machine.owner, machine.name)
205 machines = sorted(machines, key=sortkey)
206 d = dict(user=username,
207 cant_add_vm=validation.cantAddVm(username, state),
208 max_memory=max_memory,
216 def listVms(username, state, path, fields):
217 """Handler for list requests."""
218 checkpoint.checkpoint('Getting list dict')
219 d = getListDict(username, state)
220 checkpoint.checkpoint('Got list dict')
221 return templates.list(searchList=[d])
223 def vnc(username, state, path, fields):
226 Note that due to same-domain restrictions, the applet connects to
227 the webserver, which needs to forward those requests to the xen
228 server. The Xen server runs another proxy that (1) authenticates
229 and (2) finds the correct port for the VM.
231 You might want iptables like:
233 -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
234 --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
235 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
236 --dport 10003 -j SNAT --to-source 18.187.7.142
237 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
238 --dport 10003 -j ACCEPT
240 Remember to enable iptables!
241 echo 1 > /proc/sys/net/ipv4/ip_forward
243 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
245 TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
248 data["user"] = username
249 data["machine"] = machine.name
250 data["expires"] = time.time()+(5*60)
251 pickled_data = cPickle.dumps(data)
252 m = hmac.new(TOKEN_KEY, digestmod=sha)
253 m.update(pickled_data)
254 token = {'data': pickled_data, 'digest': m.digest()}
255 token = cPickle.dumps(token)
256 token = base64.urlsafe_b64encode(token)
258 status = controls.statusInfo(machine)
259 has_vnc = hasVnc(status)
261 d = dict(user=username,
265 hostname=state.environ.get('SERVER_NAME', 'localhost'),
267 return templates.vnc(searchList=[d])
269 def getHostname(nic):
270 """Find the hostname associated with a NIC.
272 XXX this should be merged with the similar logic in DNS and DHCP.
274 if nic.hostname and '.' in nic.hostname:
277 return nic.machine.name + '.xvm.mit.edu'
282 def getNicInfo(data_dict, machine):
283 """Helper function for info, get data on nics for a machine.
285 Modifies data_dict to include the relevant data, and returns a list
286 of (key, name) pairs to display "name: data_dict[key]" to the user.
288 data_dict['num_nics'] = len(machine.nics)
289 nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
290 ('nic%s_mac', 'NIC %s MAC Addr'),
291 ('nic%s_ip', 'NIC %s IP'),
294 for i in range(len(machine.nics)):
295 nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
297 data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
298 data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
299 data_dict['nic%s_ip' % i] = machine.nics[i].ip
300 if len(machine.nics) == 1:
301 nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
304 def getDiskInfo(data_dict, machine):
305 """Helper function for info, get data on disks for a machine.
307 Modifies data_dict to include the relevant data, and returns a list
308 of (key, name) pairs to display "name: data_dict[key]" to the user.
310 data_dict['num_disks'] = len(machine.disks)
311 disk_fields_template = [('%s_size', '%s size')]
313 for disk in machine.disks:
314 name = disk.guest_device_name
315 disk_fields.extend([(x % name, y % name) for x, y in
316 disk_fields_template])
317 data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
320 def command(username, state, path, fields):
321 """Handler for running commands like boot and delete on a VM."""
322 back = fields.getfirst('back')
324 d = controls.commandResult(username, state, fields)
325 if d['command'] == 'Delete VM':
327 except InvalidInput, err:
330 print >> sys.stderr, err
335 return templates.command(searchList=[d])
337 state.clear() #Changed global state
338 d = getListDict(username, state)
340 return templates.list(searchList=[d])
342 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
343 return ({'Status': '303 See Other',
344 'Location': 'info?machine_id=%d' % machine.machine_id},
345 "You shouldn't see this message.")
347 raise InvalidInput('back', back, 'Not a known back page.')
349 def modifyDict(username, state, fields):
350 """Modify a machine as specified by CGI arguments.
352 Return a list of local variables for modify.tmpl.
355 transaction = ctx.current.create_transaction()
357 kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name description memory vmtype disksize'.split()])
358 validate = validation.Validate(username, state, **kws)
359 machine = validate.machine
360 oldname = machine.name
362 if hasattr(validate, 'memory'):
363 machine.memory = validate.memory
365 if hasattr(validate, 'vmtype'):
366 machine.type = validate.vmtype
368 if hasattr(validate, 'disksize'):
369 disksize = validate.disksize
370 disk = machine.disks[0]
371 if disk.size != disksize:
372 olddisk[disk.guest_device_name] = disksize
374 ctx.current.save(disk)
377 if hasattr(validate, 'owner') and validate.owner != machine.owner:
378 machine.owner = validate.owner
380 if hasattr(validate, 'name'):
381 machine.name = validate.name
382 if hasattr(validate, 'description'):
383 machine.description = validate.description
384 if hasattr(validate, 'admin') and validate.admin != machine.administrator:
385 machine.administrator = validate.admin
387 if hasattr(validate, 'contact'):
388 machine.contact = validate.contact
390 ctx.current.save(machine)
392 print >> sys.stderr, machine, machine.administrator
393 cache_acls.refreshMachine(machine)
396 transaction.rollback()
398 for diskname in olddisk:
399 controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
400 if hasattr(validate, 'name'):
401 controls.renameMachine(machine, oldname, validate.name)
402 return dict(user=username,
406 def modify(username, state, path, fields):
407 """Handler for modifying attributes of a machine."""
409 modify_dict = modifyDict(username, state, fields)
410 except InvalidInput, err:
412 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
414 machine = modify_dict['machine']
417 info_dict = infoDict(username, state, machine)
418 info_dict['err'] = err
420 for field in fields.keys():
421 setattr(info_dict['defaults'], field, fields.getfirst(field))
422 info_dict['result'] = result
423 return templates.info(searchList=[info_dict])
426 def helpHandler(username, state, path, fields):
427 """Handler for help messages."""
428 simple = fields.getfirst('simple')
429 subjects = fields.getlist('subject')
431 help_mapping = {'ParaVM Console': """
432 ParaVM machines do not support local console access over VNC. To
433 access the serial console of these machines, you can SSH with Kerberos
434 to console.xvm.mit.edu, using the name of the machine as your
437 HVM machines use the virtualization features of the processor, while
438 ParaVM machines use Xen's emulation of virtualization features. You
439 want an HVM virtualized machine.""",
441 Don't ask us! We're as mystified as you are.""",
443 The owner field is used to determine <a
444 href="help?subject=Quotas">quotas</a>. It must be the name of a
445 locker that you are an AFS administrator of. In particular, you or an
446 AFS group you are a member of must have AFS rlidwka bits on the
447 locker. You can check who administers the LOCKER locker using the
448 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.) See also <a
449 href="help?subject=Administrator">administrator</a>.""",
451 The administrator field determines who can access the console and
452 power on and off the machine. This can be either a user or a moira
455 Quotas are determined on a per-locker basis. Each locker may have a
456 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
459 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
460 setting <tt>fb=false</tt> to disable the framebuffer. If you don't,
461 your machine will run just fine, but the applet's display of the
462 console will suffer artifacts.
467 subjects = sorted(help_mapping.keys())
469 d = dict(user=username,
472 mapping=help_mapping)
474 return templates.help(searchList=[d])
477 def badOperation(u, s, p, e):
478 """Function called when accessing an unknown URI."""
479 return ({'Status': '404 Not Found'}, 'Invalid operation.')
481 def infoDict(username, state, machine):
482 """Get the variables used by info.tmpl."""
483 status = controls.statusInfo(machine)
484 checkpoint.checkpoint('Getting status info')
485 has_vnc = hasVnc(status)
487 main_status = dict(name=machine.name,
488 memory=str(machine.memory))
492 main_status = dict(status[1:])
493 start_time = float(main_status.get('start_time', 0))
494 uptime = datetime.timedelta(seconds=int(time.time()-start_time))
495 cpu_time_float = float(main_status.get('cpu_time', 0))
496 cputime = datetime.timedelta(seconds=int(cpu_time_float))
497 checkpoint.checkpoint('Status')
498 display_fields = """name uptime memory state cpu_weight on_reboot
499 on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
500 display_fields = [('name', 'Name'),
501 ('description', 'Description'),
503 ('administrator', 'Administrator'),
504 ('contact', 'Contact'),
507 ('uptime', 'uptime'),
508 ('cputime', 'CPU usage'),
511 ('state', 'state (xen format)'),
512 ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
513 ('on_reboot', 'Action on VM reboot'),
514 ('on_poweroff', 'Action on VM poweroff'),
515 ('on_crash', 'Action on VM crash'),
516 ('on_xend_start', 'Action on Xen start'),
517 ('on_xend_stop', 'Action on Xen stop'),
518 ('bootloader', 'Bootloader options'),
522 machine_info['name'] = machine.name
523 machine_info['description'] = machine.description
524 machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
525 machine_info['owner'] = machine.owner
526 machine_info['administrator'] = machine.administrator
527 machine_info['contact'] = machine.contact
529 nic_fields = getNicInfo(machine_info, machine)
530 nic_point = display_fields.index('NIC_INFO')
531 display_fields = (display_fields[:nic_point] + nic_fields +
532 display_fields[nic_point+1:])
534 disk_fields = getDiskInfo(machine_info, machine)
535 disk_point = display_fields.index('DISK_INFO')
536 display_fields = (display_fields[:disk_point] + disk_fields +
537 display_fields[disk_point+1:])
539 main_status['memory'] += ' MiB'
540 for field, disp in display_fields:
541 if field in ('uptime', 'cputime') and locals()[field] is not None:
542 fields.append((disp, locals()[field]))
543 elif field in machine_info:
544 fields.append((disp, machine_info[field]))
545 elif field in main_status:
546 fields.append((disp, main_status[field]))
549 #fields.append((disp, None))
551 checkpoint.checkpoint('Got fields')
554 max_mem = validation.maxMemory(machine.owner, state, machine, False)
555 checkpoint.checkpoint('Got mem')
556 max_disk = validation.maxDisk(machine.owner, machine)
557 defaults = Defaults()
558 for name in 'machine_id name description administrator owner memory contact'.split():
559 setattr(defaults, name, getattr(machine, name))
560 defaults.type = machine.type.type_id
561 defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
562 checkpoint.checkpoint('Got defaults')
563 d = dict(user=username,
564 on=status is not None,
572 owner_help=helppopup("Owner"),
576 def info(username, state, path, fields):
577 """Handler for info on a single VM."""
578 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
579 d = infoDict(username, state, machine)
580 checkpoint.checkpoint('Got infodict')
581 return templates.info(searchList=[d])
583 def unauthFront(_, _2, _3, fields):
584 """Information for unauth'd users."""
585 return templates.unauth(searchList=[{'simple' : True}])
587 def overlord(username, state, path, fields):
589 return ({'Status': '303 See Other',
590 'Location': 'overlord/'},
591 "You shouldn't see this message.")
592 if not username in getAfsGroupMembers('system:xvm', 'athena.mit.edu'):
593 raise InvalidInput('username', username, 'Not an overlord.')
594 newstate = State(username, overlord=True)
595 newstate.environ = state.environ
596 return handler(username, newstate, path, fields)
598 def throwError(_, __, ___, ____):
599 """Throw an error, to test the error-tracing mechanisms."""
600 raise RuntimeError("test of the emergency broadcast system")
602 mapping = dict(list=listVms,
611 errortest=throwError)
613 def printHeaders(headers):
614 """Print a dictionary as HTTP headers."""
615 for key, value in headers.iteritems():
616 print '%s: %s' % (key, value)
619 def send_error_mail(subject, body):
624 From: root@xvm.mit.edu
628 """ % (to, subject, body)
629 p = subprocess.Popen(['/usr/sbin/sendmail', to], stdin=subprocess.PIPE)
634 def show_error(op, username, fields, err, emsg, traceback):
635 """Print an error page when an exception occurs"""
636 d = dict(op=op, user=username, fields=fields,
637 errorMessage=str(err), stderr=emsg, traceback=traceback)
638 details = templates.error_raw(searchList=[d])
639 if username not in ('price', 'ecprice', 'andersk'): #add yourself at will
640 send_error_mail('xvm error on %s for %s: %s' % (op, username, err),
642 d['details'] = details
643 return templates.error(searchList=[d])
645 def getUser(environ):
646 """Return the current user based on the SSL environment variables"""
647 return environ.get('REMOTE_USER', None)
649 def handler(username, state, path, fields):
650 operation, path = pathSplit(path)
653 print 'Starting', operation
654 fun = mapping.get(operation, badOperation)
655 return fun(username, state, path, fields)
658 def __init__(self, environ, start_response):
659 self.environ = environ
660 self.start = start_response
662 self.username = getUser(environ)
663 self.state = State(self.username)
664 self.state.environ = environ
667 start_time = time.time()
668 sipb_xen_database.clear_cache()
669 sys.stderr = StringIO()
670 fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
671 operation = self.environ.get('PATH_INFO', '')
673 self.start("301 Moved Permanently", [('Location', './')])
675 if self.username is None:
679 checkpoint.checkpoint('Before')
680 output = handler(self.username, self.state, operation, fields)
681 checkpoint.checkpoint('After')
683 headers = dict(DEFAULT_HEADERS)
684 if isinstance(output, tuple):
685 new_headers, output = output
686 headers.update(new_headers)
687 e = revertStandardError()
689 if isinstance(output, basestring):
690 sys.stderr = StringIO()
692 print >> sys.stderr, x
693 print >> sys.stderr, 'XXX'
694 print >> sys.stderr, e
697 output_string = str(output)
698 checkpoint.checkpoint('output as a string')
699 except Exception, err:
700 if not fields.has_key('js'):
701 if isinstance(err, InvalidInput):
702 self.start('200 OK', [('Content-Type', 'text/html')])
703 e = revertStandardError()
704 yield str(invalidInput(operation, self.username, fields,
708 self.start('500 Internal Server Error',
709 [('Content-Type', 'text/html')])
710 e = revertStandardError()
711 s = show_error(operation, self.username, fields,
712 err, e, traceback.format_exc())
715 status = headers.setdefault('Status', '200 OK')
716 del headers['Status']
717 self.start(status, headers.items())
719 if fields.has_key('timedebug'):
720 yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
723 connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
727 from flup.server.fcgi_fork import WSGIServer
728 WSGIServer(constructor()).run()
730 if __name__ == '__main__':