2 """Main CGI script for web interface"""
15 from StringIO import StringIO
17 def revertStandardError():
18 """Move stderr to stdout, and return the contents of the old stderr."""
20 if not isinstance(errio, StringIO):
22 sys.stderr = sys.stdout
27 """Revert stderr to stdout, and print the contents of stderr"""
28 if isinstance(sys.stderr, StringIO):
29 print revertStandardError()
31 if __name__ == '__main__':
33 atexit.register(printError)
36 from Cheetah.Template import Template
39 from webcommon import InvalidInput, CodeError, State
41 from getafsgroups import getAfsGroupMembers
42 from invirt import database
43 from invirt.database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
44 from invirt.config import structs as config
47 if path.startswith('/'):
52 return path[:i], path[i:]
56 self.start_time = time.time()
59 def checkpoint(self, s):
60 self.checkpoints.append((s, time.time()))
63 return ('Timing info:\n%s\n' %
64 '\n'.join(['%s: %s' % (d, t - self.start_time) for
65 (d, t) in self.checkpoints]))
67 checkpoint = Checkpoint()
70 return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
73 """Return HTML code for a (?) link to a specified help topic"""
74 return ('<span class="helplink"><a href="help?' +
75 cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
76 +'" target="_blank" ' +
77 'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
79 def makeErrorPre(old, addition):
83 return old[:-6] + '\n----\n' + str(addition) + '</pre>'
85 return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
87 Template.database = database
88 Template.helppopup = staticmethod(helppopup)
92 """Class to store a dictionary that will be converted to JSON"""
93 def __init__(self, **kws):
101 return simplejson.dumps(self.data)
103 def addError(self, text):
104 """Add stderr text to be displayed on the website."""
106 makeErrorPre(self.data.get('err'), text)
109 """Class to store default values for fields."""
118 def __init__(self, max_memory=None, max_disk=None, **kws):
119 if max_memory is not None:
120 self.memory = min(self.memory, max_memory)
121 if max_disk is not None:
122 self.max_disk = min(self.disk, max_disk)
124 setattr(self, key, kws[key])
128 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
130 def invalidInput(op, username, fields, err, emsg):
131 """Print an error page when an InvalidInput exception occurs"""
132 d = dict(op=op, user=username, err_field=err.err_field,
133 err_value=str(err.err_value), stderr=emsg,
134 errorMessage=str(err))
135 return templates.invalid(searchList=[d])
138 """Does the machine with a given status list support VNC?"""
142 if l[0] == 'device' and l[1][0] == 'vfb':
144 return 'location' in d
147 def parseCreate(username, state, fields):
148 kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()])
149 validate = validation.Validate(username, state, strict=True, **kws)
150 return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory,
151 disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
152 cdrom=getattr(validate, 'cdrom', None),
153 autoinstall=getattr(validate, 'autoinstall', None))
155 def create(username, state, path, fields):
156 """Handler for create requests."""
158 parsed_fields = parseCreate(username, state, fields)
159 machine = controls.createVm(username, state, **parsed_fields)
160 except InvalidInput, err:
164 state.clear() #Changed global state
165 d = getListDict(username, state)
168 for field in fields.keys():
169 setattr(d['defaults'], field, fields.getfirst(field))
171 d['new_machine'] = parsed_fields['name']
172 return templates.list(searchList=[d])
175 def getListDict(username, state):
176 """Gets the list of local variables used by list.tmpl."""
177 checkpoint.checkpoint('Starting')
178 machines = state.machines
179 checkpoint.checkpoint('Got my machines')
182 xmlist = state.xmlist
183 checkpoint.checkpoint('Got uptimes')
184 can_clone = 'ice3' not in state.xmlist_raw
190 m.uptime = xmlist[m]['uptime']
191 if xmlist[m]['console']:
196 has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
197 max_memory = validation.maxMemory(username, state)
198 max_disk = validation.maxDisk(username)
199 checkpoint.checkpoint('Got max mem/disk')
200 defaults = Defaults(max_memory=max_memory,
204 checkpoint.checkpoint('Got defaults')
205 def sortkey(machine):
206 return (machine.owner != username, machine.owner, machine.name)
207 machines = sorted(machines, key=sortkey)
208 d = dict(user=username,
209 cant_add_vm=validation.cantAddVm(username, state),
210 max_memory=max_memory,
218 def listVms(username, state, path, fields):
219 """Handler for list requests."""
220 checkpoint.checkpoint('Getting list dict')
221 d = getListDict(username, state)
222 checkpoint.checkpoint('Got list dict')
223 return templates.list(searchList=[d])
225 def vnc(username, state, path, fields):
228 Note that due to same-domain restrictions, the applet connects to
229 the webserver, which needs to forward those requests to the xen
230 server. The Xen server runs another proxy that (1) authenticates
231 and (2) finds the correct port for the VM.
233 You might want iptables like:
235 -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
236 --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
237 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
238 --dport 10003 -j SNAT --to-source 18.187.7.142
239 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
240 --dport 10003 -j ACCEPT
242 Remember to enable iptables!
243 echo 1 > /proc/sys/net/ipv4/ip_forward
245 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
247 TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
250 data["user"] = username
251 data["machine"] = machine.name
252 data["expires"] = time.time()+(5*60)
253 pickled_data = cPickle.dumps(data)
254 m = hmac.new(TOKEN_KEY, digestmod=sha)
255 m.update(pickled_data)
256 token = {'data': pickled_data, 'digest': m.digest()}
257 token = cPickle.dumps(token)
258 token = base64.urlsafe_b64encode(token)
259 host = controls.listHost(machine)
261 port = 10003 + [h.hostname for h in config.hosts].index(host)
265 status = controls.statusInfo(machine)
266 has_vnc = hasVnc(status)
268 d = dict(user=username,
272 hostname=state.environ.get('SERVER_NAME', 'localhost'),
275 return templates.vnc(searchList=[d])
277 def getHostname(nic):
278 """Find the hostname associated with a NIC.
280 XXX this should be merged with the similar logic in DNS and DHCP.
282 if nic.hostname and '.' in nic.hostname:
285 return nic.machine.name + '.' + config.dns.domains[0]
290 def getNicInfo(data_dict, machine):
291 """Helper function for info, get data on nics for a machine.
293 Modifies data_dict to include the relevant data, and returns a list
294 of (key, name) pairs to display "name: data_dict[key]" to the user.
296 data_dict['num_nics'] = len(machine.nics)
297 nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
298 ('nic%s_mac', 'NIC %s MAC Addr'),
299 ('nic%s_ip', 'NIC %s IP'),
302 for i in range(len(machine.nics)):
303 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.
363 transaction = ctx.current.create_transaction()
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 ctx.current.save(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 if hasattr(validate, 'description'):
391 machine.description = validate.description
392 if hasattr(validate, 'admin') and validate.admin != machine.administrator:
393 machine.administrator = validate.admin
395 if hasattr(validate, 'contact'):
396 machine.contact = validate.contact
398 ctx.current.save(machine)
400 print >> sys.stderr, machine, machine.administrator
401 cache_acls.refreshMachine(machine)
404 transaction.rollback()
406 for diskname in olddisk:
407 controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
408 if hasattr(validate, 'name'):
409 controls.renameMachine(machine, oldname, validate.name)
410 return dict(user=username,
414 def modify(username, state, path, fields):
415 """Handler for modifying attributes of a machine."""
417 modify_dict = modifyDict(username, state, fields)
418 except InvalidInput, err:
420 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
422 machine = modify_dict['machine']
425 info_dict = infoDict(username, state, machine)
426 info_dict['err'] = err
428 for field in fields.keys():
429 setattr(info_dict['defaults'], field, fields.getfirst(field))
430 info_dict['result'] = result
431 return templates.info(searchList=[info_dict])
434 def helpHandler(username, state, path, fields):
435 """Handler for help messages."""
436 simple = fields.getfirst('simple')
437 subjects = fields.getlist('subject')
439 help_mapping = {'ParaVM Console': """
440 ParaVM machines do not support local console access over VNC. To
441 access the serial console of these machines, you can SSH with Kerberos
442 to console.%s, using the name of the machine as your
443 username.""" % config.dns.domains[0],
445 HVM machines use the virtualization features of the processor, while
446 ParaVM machines use Xen's emulation of virtualization features. You
447 want an HVM virtualized machine.""",
449 Don't ask us! We're as mystified as you are.""",
451 The owner field is used to determine <a
452 href="help?subject=Quotas">quotas</a>. It must be the name of a
453 locker that you are an AFS administrator of. In particular, you or an
454 AFS group you are a member of must have AFS rlidwka bits on the
455 locker. You can check who administers the LOCKER locker using the
456 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.) See also <a
457 href="help?subject=Administrator">administrator</a>.""",
459 The administrator field determines who can access the console and
460 power on and off the machine. This can be either a user or a moira
463 Quotas are determined on a per-locker basis. Each locker may have a
464 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
467 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
468 setting <tt>fb=false</tt> to disable the framebuffer. If you don't,
469 your machine will run just fine, but the applet's display of the
470 console will suffer artifacts.
475 subjects = sorted(help_mapping.keys())
477 d = dict(user=username,
480 mapping=help_mapping)
482 return templates.help(searchList=[d])
485 def badOperation(u, s, p, e):
486 """Function called when accessing an unknown URI."""
487 return ({'Status': '404 Not Found'}, 'Invalid operation.')
489 def infoDict(username, state, machine):
490 """Get the variables used by info.tmpl."""
491 status = controls.statusInfo(machine)
492 checkpoint.checkpoint('Getting status info')
493 has_vnc = hasVnc(status)
495 main_status = dict(name=machine.name,
496 memory=str(machine.memory))
500 main_status = dict(status[1:])
501 main_status['host'] = controls.listHost(machine)
502 start_time = float(main_status.get('start_time', 0))
503 uptime = datetime.timedelta(seconds=int(time.time()-start_time))
504 cpu_time_float = float(main_status.get('cpu_time', 0))
505 cputime = datetime.timedelta(seconds=int(cpu_time_float))
506 checkpoint.checkpoint('Status')
507 display_fields = """name uptime memory state cpu_weight on_reboot
508 on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
509 display_fields = [('name', 'Name'),
510 ('description', 'Description'),
512 ('administrator', 'Administrator'),
513 ('contact', 'Contact'),
516 ('uptime', 'uptime'),
517 ('cputime', 'CPU usage'),
518 ('host', 'Hosted on'),
521 ('state', 'state (xen format)'),
522 ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
523 ('on_reboot', 'Action on VM reboot'),
524 ('on_poweroff', 'Action on VM poweroff'),
525 ('on_crash', 'Action on VM crash'),
526 ('on_xend_start', 'Action on Xen start'),
527 ('on_xend_stop', 'Action on Xen stop'),
528 ('bootloader', 'Bootloader options'),
532 machine_info['name'] = machine.name
533 machine_info['description'] = machine.description
534 machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
535 machine_info['owner'] = machine.owner
536 machine_info['administrator'] = machine.administrator
537 machine_info['contact'] = machine.contact
539 nic_fields = getNicInfo(machine_info, machine)
540 nic_point = display_fields.index('NIC_INFO')
541 display_fields = (display_fields[:nic_point] + nic_fields +
542 display_fields[nic_point+1:])
544 disk_fields = getDiskInfo(machine_info, machine)
545 disk_point = display_fields.index('DISK_INFO')
546 display_fields = (display_fields[:disk_point] + disk_fields +
547 display_fields[disk_point+1:])
549 main_status['memory'] += ' MiB'
550 for field, disp in display_fields:
551 if field in ('uptime', 'cputime') and locals()[field] is not None:
552 fields.append((disp, locals()[field]))
553 elif field in machine_info:
554 fields.append((disp, machine_info[field]))
555 elif field in main_status:
556 fields.append((disp, main_status[field]))
559 #fields.append((disp, None))
561 checkpoint.checkpoint('Got fields')
564 max_mem = validation.maxMemory(machine.owner, state, machine, False)
565 checkpoint.checkpoint('Got mem')
566 max_disk = validation.maxDisk(machine.owner, machine)
567 defaults = Defaults()
568 for name in 'machine_id name description administrator owner memory contact'.split():
569 setattr(defaults, name, getattr(machine, name))
570 defaults.type = machine.type.type_id
571 defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
572 checkpoint.checkpoint('Got defaults')
573 d = dict(user=username,
574 on=status is not None,
582 owner_help=helppopup("Owner"),
586 def info(username, state, path, fields):
587 """Handler for info on a single VM."""
588 machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
589 d = infoDict(username, state, machine)
590 checkpoint.checkpoint('Got infodict')
591 return templates.info(searchList=[d])
593 def unauthFront(_, _2, _3, fields):
594 """Information for unauth'd users."""
595 return templates.unauth(searchList=[{'simple' : True}])
597 def overlord(username, state, path, fields):
599 return ({'Status': '303 See Other',
600 'Location': 'overlord/'},
601 "You shouldn't see this message.")
602 if not username in getAfsGroupMembers('system:xvm', 'athena.mit.edu'):
603 raise InvalidInput('username', username, 'Not an overlord.')
604 newstate = State(username, overlord=True)
605 newstate.environ = state.environ
606 return handler(username, newstate, path, fields)
608 def throwError(_, __, ___, ____):
609 """Throw an error, to test the error-tracing mechanisms."""
610 raise RuntimeError("test of the emergency broadcast system")
612 mapping = dict(list=listVms,
621 errortest=throwError)
623 def printHeaders(headers):
624 """Print a dictionary as HTTP headers."""
625 for key, value in headers.iteritems():
626 print '%s: %s' % (key, value)
629 def send_error_mail(subject, body):
632 to = config.web.errormail
638 """ % (to, config.web.hostname, subject, body)
639 p = subprocess.Popen(['/usr/sbin/sendmail', to], stdin=subprocess.PIPE)
644 def show_error(op, username, fields, err, emsg, traceback):
645 """Print an error page when an exception occurs"""
646 d = dict(op=op, user=username, fields=fields,
647 errorMessage=str(err), stderr=emsg, traceback=traceback)
648 details = templates.error_raw(searchList=[d])
649 if username not in ('price', 'ecprice', 'andersk'): #add yourself at will
650 send_error_mail('xvm error on %s for %s: %s' % (op, username, err),
652 d['details'] = details
653 return templates.error(searchList=[d])
655 def getUser(environ):
656 """Return the current user based on the SSL environment variables"""
657 return environ.get('REMOTE_USER', None)
659 def handler(username, state, path, fields):
660 operation, path = pathSplit(path)
663 print 'Starting', operation
664 fun = mapping.get(operation, badOperation)
665 return fun(username, state, path, fields)
668 def __init__(self, environ, start_response):
669 self.environ = environ
670 self.start = start_response
672 self.username = getUser(environ)
673 self.state = State(self.username)
674 self.state.environ = environ
679 start_time = time.time()
680 database.clear_cache()
681 sys.stderr = StringIO()
682 fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
683 operation = self.environ.get('PATH_INFO', '')
685 self.start("301 Moved Permanently", [('Location', './')])
687 if self.username is None:
691 checkpoint.checkpoint('Before')
692 output = handler(self.username, self.state, operation, fields)
693 checkpoint.checkpoint('After')
695 headers = dict(DEFAULT_HEADERS)
696 if isinstance(output, tuple):
697 new_headers, output = output
698 headers.update(new_headers)
699 e = revertStandardError()
701 if hasattr(output, 'addError'):
704 # This only happens on redirects, so it'd be a pain to get
705 # the message to the user. Maybe in the response is useful.
706 output = output + '\n\nstderr:\n' + e
707 output_string = str(output)
708 checkpoint.checkpoint('output as a string')
709 except Exception, err:
710 if not fields.has_key('js'):
711 if isinstance(err, InvalidInput):
712 self.start('200 OK', [('Content-Type', 'text/html')])
713 e = revertStandardError()
714 yield str(invalidInput(operation, self.username, fields,
718 self.start('500 Internal Server Error',
719 [('Content-Type', 'text/html')])
720 e = revertStandardError()
721 s = show_error(operation, self.username, fields,
722 err, e, traceback.format_exc())
725 status = headers.setdefault('Status', '200 OK')
726 del headers['Status']
727 self.start(status, headers.items())
729 if fields.has_key('timedebug'):
730 yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
737 from flup.server.fcgi_fork import WSGIServer
738 WSGIServer(constructor()).run()
740 if __name__ == '__main__':