18 sys.stderr = StringIO.StringIO()
19 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
21 from Cheetah.Template import Template
22 from sipb_xen_database import *
25 class MyException(Exception):
26 """Base class for my exceptions"""
29 class InvalidInput(MyException):
30 """Exception for user-provided input is invalid but maybe in good faith.
32 This would include setting memory to negative (which might be a
33 typo) but not setting an invalid boot CD (which requires bypassing
36 def __init__(self, err_field, err_value, expl=None):
37 MyException.__init__(self, expl)
38 self.err_field = err_field
39 self.err_value = err_value
41 class CodeError(MyException):
42 """Exception for internal errors or bad faith input."""
46 def __init__(self, user):
49 def __get_uptimes(self):
50 if not hasattr(self, '_uptimes'):
51 self._uptimes = getUptimes(Machine.select())
53 uptimes = property(__get_uptimes)
58 """Return HTML code for a (?) link to a specified help topic"""
59 return '<span class="helplink"><a href="help?subject='+subj+'&simple=true" target="_blank" onclick="return helppopup(\''+subj+'\')">(?)</a></span>'
63 global_dict['helppopup'] = helppopup
66 # ... and stolen from xend/uuid.py
68 """Generate a random UUID."""
70 return [ random.randint(0, 255) for _ in range(0, 16) ]
73 """Turn a numeric UUID to a hyphen-seperated one."""
74 return "-".join(["%02x" * 4, "%02x" * 2, "%02x" * 2, "%02x" * 2,
75 "%02x" * 6]) % tuple(u)
77 MAX_MEMORY_TOTAL = 512
78 MAX_MEMORY_SINGLE = 256
79 MIN_MEMORY_SINGLE = 16
86 def getMachinesByOwner(owner):
87 """Return the machines owned by a given owner."""
88 return Machine.select_by(owner=owner)
90 def maxMemory(user, machine=None, on=True):
91 """Return the maximum memory for a machine or a user.
93 If machine is None, return the memory available for a new
94 machine. Else, return the maximum that machine can have.
96 on is whether the machine should be turned on. If false, the max
97 memory for the machine to change to, if it is left off, is
101 return MAX_MEMORY_SINGLE
102 machines = getMachinesByOwner(user.username)
103 active_machines = [x for x in machines if g.uptimes[x]]
104 mem_usage = sum([x.memory for x in active_machines if x != machine])
105 return min(MAX_MEMORY_SINGLE, MAX_MEMORY_TOTAL-mem_usage)
107 def maxDisk(user, machine=None):
108 machines = getMachinesByOwner(user.username)
109 disk_usage = sum([sum([y.size for y in x.disks])
110 for x in machines if x != machine])
111 return min(MAX_DISK_SINGLE, MAX_DISK_TOTAL-disk_usage/1024.)
114 machines = getMachinesByOwner(user.username)
115 active_machines = [x for x in machines if g.uptimes[x]]
116 return (len(machines) < MAX_VMS_TOTAL and
117 len(active_machines) < MAX_VMS_ACTIVE)
119 def haveAccess(user, machine):
120 """Return whether a user has access to a machine"""
121 if user.username == 'moo':
123 return getafsgroups.checkLockerOwner(user.username, machine.owner)
125 def error(op, user, fields, err, emsg):
126 """Print an error page when a CodeError occurs"""
127 d = dict(op=op, user=user, errorMessage=str(err),
129 return Template(file='error.tmpl', searchList=[d, global_dict]);
131 def invalidInput(op, user, fields, err, emsg):
132 """Print an error page when an InvalidInput exception occurs"""
133 d = dict(op=op, user=user, err_field=err.err_field,
134 err_value=str(err.err_value), stderr=emsg,
135 errorMessage=str(err))
136 return Template(file='invalid.tmpl', searchList=[d, global_dict]);
138 def validMachineName(name):
139 """Check that name is valid for a machine name"""
142 charset = string.ascii_letters + string.digits + '-_'
143 if name[0] in '-_' or len(name) > 22:
150 def kinit(username = 'tabbott/extra', keytab = '/etc/tabbott.keytab'):
151 """Kinit with a given username and keytab"""
153 p = subprocess.Popen(['kinit', "-k", "-t", keytab, username],
154 stderr=subprocess.PIPE)
157 raise CodeError("Error %s in kinit: %s" % (e, p.stderr.read()))
160 """If we lack tickets, kinit."""
161 p = subprocess.Popen(['klist', '-s'])
165 def remctl(*args, **kws):
166 """Perform a remctl and return the output.
168 kinits if necessary, and outputs errors to stderr.
171 p = subprocess.Popen(['remctl', 'black-mesa.mit.edu']
173 stdout=subprocess.PIPE,
174 stderr=subprocess.PIPE)
177 return p.stdout.read(), p.stderr.read()
179 print >> sys.stderr, 'Error on remctl', args, ':'
180 print >> sys.stderr, p.stderr.read()
181 raise CodeError('ERROR on remctl')
182 return p.stdout.read()
184 def lvcreate(machine, disk):
185 """Create a single disk for a machine"""
186 remctl('web', 'lvcreate', machine.name,
187 disk.guest_device_name, str(disk.size))
189 def makeDisks(machine):
190 """Update the lvm partitions to add a disk."""
191 for disk in machine.disks:
192 lvcreate(machine, disk)
194 def bootMachine(machine, cdtype):
195 """Boot a machine with a given boot CD.
197 If cdtype is None, give no boot cd. Otherwise, it is the string
198 id of the CD (e.g. 'gutsy_i386')
200 if cdtype is not None:
201 remctl('web', 'vmboot', machine.name,
204 remctl('web', 'vmboot', machine.name)
206 def registerMachine(machine):
207 """Register a machine to be controlled by the web interface"""
208 remctl('web', 'register', machine.name)
210 def unregisterMachine(machine):
211 """Unregister a machine to not be controlled by the web interface"""
212 remctl('web', 'unregister', machine.name)
215 """Parse a status string into nested tuples of strings.
217 s = output of xm list --long <machine_name>
219 values = re.split('([()])', s)
221 for v in values[2:-2]: #remove initial and final '()'
228 if len(stack[-1]) == 1:
230 stack[-2].append(stack[-1])
235 stack[-1].extend(v.split())
238 def getUptimes(machines=None):
239 """Return a dictionary mapping machine names to uptime strings"""
240 value_string = remctl('web', 'listvms')
241 lines = value_string.splitlines()
246 uptime = ' '.join(lst[2:])
250 ans[m] = d.get(m.name)
253 def statusInfo(machine):
254 """Return the status list for a given machine.
256 Gets and parses xm list --long
258 value_string, err_string = remctl('list-long', machine.name, err=True)
259 if 'Unknown command' in err_string:
260 raise CodeError("ERROR in remctl list-long %s is not registered" % (machine.name,))
261 elif 'does not exist' in err_string:
264 raise CodeError("ERROR in remctl list-long %s: %s" % (machine.name, err_string))
265 status = parseStatus(value_string)
269 """Does the machine with a given status list support VNC?"""
273 if l[0] == 'device' and l[1][0] == 'vfb':
275 return 'location' in d
278 def createVm(user, name, memory, disk, is_hvm, cdrom):
279 """Create a VM and put it in the database"""
280 # put stuff in the table
281 transaction = ctx.current.create_transaction()
283 if memory > maxMemory(user):
284 raise InvalidInput('memory', memory,
285 "Max %s" % maxMemory(user))
286 if disk > maxDisk(user) * 1024:
287 raise InvalidInput('disk', disk,
288 "Max %s" % maxDisk(user))
289 if not canAddVm(user):
290 raise InvalidInput('create', True, 'Unable to create more VMs')
291 res = meta.engine.execute('select nextval(\'"machines_machine_id_seq"\')')
292 id = res.fetchone()[0]
294 machine.machine_id = id
296 machine.memory = memory
297 machine.owner = user.username
298 machine.administrator = user.username
299 machine.contact = user.email
300 machine.uuid = uuidToString(randomUUID())
301 machine.boot_off_cd = True
302 machine_type = Type.get_by(hvm=is_hvm)
303 machine.type_id = machine_type.type_id
304 ctx.current.save(machine)
305 disk = Disk(machine.machine_id,
307 open = NIC.select_by(machine_id=None)
308 if not open: #No IPs left!
309 raise CodeError("No IP addresses left! Contact sipb-xen-dev@mit.edu")
311 nic.machine_id = machine.machine_id
313 ctx.current.save(nic)
314 ctx.current.save(disk)
317 transaction.rollback()
319 registerMachine(machine)
321 # tell it to boot with cdrom
322 bootMachine(machine, cdrom)
326 def validMemory(user, memory, machine=None, on=True):
327 """Parse and validate limits for memory for a given user and machine.
329 on is whether the memory must be valid after the machine is
334 if memory < MIN_MEMORY_SINGLE:
337 raise InvalidInput('memory', memory,
338 "Minimum %s MB" % MIN_MEMORY_SINGLE)
339 if memory > maxMemory(user, machine, on):
340 raise InvalidInput('memory', memory,
341 'Maximum %s MB' % maxMemory(user, machine))
344 def validDisk(user, disk, machine=None):
345 """Parse and validate limits for disk for a given user and machine."""
348 if disk > maxDisk(user, machine):
349 raise InvalidInput('disk', disk,
350 "Maximum %s G" % maxDisk(user, machine))
351 disk = int(disk * 1024)
352 if disk < MIN_DISK_SINGLE * 1024:
355 raise InvalidInput('disk', disk,
356 "Minimum %s GB" % MIN_DISK_SINGLE)
359 def create(user, fields):
360 """Handler for create requests."""
361 name = fields.getfirst('name')
362 if not validMachineName(name):
363 raise InvalidInput('name', name)
366 if Machine.get_by(name=name):
367 raise InvalidInput('name', name,
370 memory = fields.getfirst('memory')
371 memory = validMemory(user, memory, on=True)
373 disk = fields.getfirst('disk')
374 disk = validDisk(user, disk)
376 vm_type = fields.getfirst('vmtype')
377 if vm_type not in ('hvm', 'paravm'):
378 raise CodeError("Invalid vm type '%s'" % vm_type)
379 is_hvm = (vm_type == 'hvm')
381 cdrom = fields.getfirst('cdrom')
382 if cdrom is not None and not CDROM.get(cdrom):
383 raise CodeError("Invalid cdrom type '%s'" % cdrom)
385 machine = createVm(user, name, memory, disk, is_hvm, cdrom)
388 return Template(file='create.tmpl',
389 searchList=[d, global_dict]);
391 def listVms(user, fields):
392 """Handler for list requests."""
393 machines = [m for m in Machine.select() if haveAccess(user, m)]
403 has_vnc[m] = "ParaVM"+helppopup("paravm_console")
405 # status = statusInfo(m)
406 # on[m.name] = status is not None
407 # has_vnc[m.name] = hasVnc(status)
408 max_mem=maxMemory(user)
409 max_disk=maxDisk(user)
411 can_add_vm=canAddVm(user),
415 default_disk=min(4.0, max_disk),
419 cdroms=CDROM.select())
420 return Template(file='list.tmpl', searchList=[d, global_dict])
422 def testMachineId(user, machineId, exists=True):
423 """Parse, validate and check authorization for a given machineId.
425 If exists is False, don't check that it exists.
427 if machineId is None:
428 raise CodeError("No machine ID specified")
430 machineId = int(machineId)
432 raise CodeError("Invalid machine ID '%s'" % machineId)
433 machine = Machine.get(machineId)
434 if exists and machine is None:
435 raise CodeError("No such machine ID '%s'" % machineId)
436 if machine is not None and not haveAccess(user, machine):
437 raise CodeError("No access to machine ID '%s'" % machineId)
440 def vnc(user, fields):
443 Note that due to same-domain restrictions, the applet connects to
444 the webserver, which needs to forward those requests to the xen
445 server. The Xen server runs another proxy that (1) authenticates
446 and (2) finds the correct port for the VM.
448 You might want iptables like:
450 -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
451 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp --dport 10003 -j SNAT --to-source 18.187.7.142
452 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp --dport 10003 -j ACCEPT
454 Remember to enable iptables!
455 echo 1 > /proc/sys/net/ipv4/ip_forward
457 machine = testMachineId(user, fields.getfirst('machine_id'))
459 TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
462 data["user"] = user.username
463 data["machine"]=machine.name
464 data["expires"]=time.time()+(5*60)
465 pickledData = cPickle.dumps(data)
466 m = hmac.new(TOKEN_KEY, digestmod=sha)
467 m.update(pickledData)
468 token = {'data': pickledData, 'digest': m.digest()}
469 token = cPickle.dumps(token)
470 token = base64.urlsafe_b64encode(token)
472 status = statusInfo(machine)
473 has_vnc = hasVnc(status)
479 hostname=os.environ.get('SERVER_NAME', 'localhost'),
481 return Template(file='vnc.tmpl',
482 searchList=[d, global_dict])
484 def getNicInfo(data_dict, machine):
485 """Helper function for info, get data on nics for a machine.
487 Modifies data_dict to include the relevant data, and returns a list
488 of (key, name) pairs to display "name: data_dict[key]" to the user.
490 data_dict['num_nics'] = len(machine.nics)
491 nic_fields_template = [('nic%s_hostname', 'NIC %s hostname'),
492 ('nic%s_mac', 'NIC %s MAC Addr'),
493 ('nic%s_ip', 'NIC %s IP'),
496 for i in range(len(machine.nics)):
497 nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
498 data_dict['nic%s_hostname' % i] = machine.nics[i].hostname + '.servers.csail.mit.edu'
499 data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
500 data_dict['nic%s_ip' % i] = machine.nics[i].ip
501 if len(machine.nics) == 1:
502 nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
505 def getDiskInfo(data_dict, machine):
506 """Helper function for info, get data on disks for a machine.
508 Modifies data_dict to include the relevant data, and returns a list
509 of (key, name) pairs to display "name: data_dict[key]" to the user.
511 data_dict['num_disks'] = len(machine.disks)
512 disk_fields_template = [('%s_size', '%s size')]
514 for disk in machine.disks:
515 name = disk.guest_device_name
516 disk_fields.extend([(x % name, y % name) for x, y in disk_fields_template])
517 data_dict['%s_size' % name] = "%0.1f GB" % (disk.size / 1024.)
520 def deleteVM(machine):
522 remctl('destroy', machine.name, err=True)
523 transaction = ctx.current.create_transaction()
524 delete_disk_pairs = [(machine.name, d.guest_device_name) for d in machine.disks]
526 for nic in machine.nics:
527 nic.machine_id = None
529 ctx.current.save(nic)
530 for disk in machine.disks:
531 ctx.current.delete(disk)
532 ctx.current.delete(machine)
535 transaction.rollback()
537 for mname, dname in delete_disk_pairs:
538 remctl('web', 'lvremove', mname, dname)
539 unregisterMachine(machine)
541 def command(user, fields):
542 """Handler for running commands like boot and delete on a VM."""
543 print >> sys.stderr, time.time()-start_time
544 machine = testMachineId(user, fields.getfirst('machine_id'))
545 action = fields.getfirst('action')
546 cdrom = fields.getfirst('cdrom')
547 print >> sys.stderr, time.time()-start_time
548 if cdrom is not None and not CDROM.get(cdrom):
549 raise CodeError("Invalid cdrom type '%s'" % cdrom)
550 if action not in ('Reboot', 'Power on', 'Power off', 'Shutdown', 'Delete VM'):
551 raise CodeError("Invalid action '%s'" % action)
552 if action == 'Reboot':
553 if cdrom is not None:
554 remctl('reboot', machine.name, cdrom)
556 remctl('reboot', machine.name)
557 elif action == 'Power on':
558 if maxMemory(user) < machine.memory:
559 raise InvalidInput('action', 'Power on',
560 "You don't have enough free RAM quota to turn on this machine")
561 bootMachine(machine, cdrom)
562 elif action == 'Power off':
563 remctl('destroy', machine.name)
564 elif action == 'Shutdown':
565 remctl('shutdown', machine.name)
566 elif action == 'Delete VM':
568 print >> sys.stderr, time.time()-start_time
573 return Template(file="command.tmpl", searchList=[d, global_dict])
575 def testOwner(user, owner, machine=None):
576 if owner == machine.owner: #XXX What do we do when you lose access to the locker?
578 value = getafsgroups.checkLockerOwner(user.username, owner, verbose=True)
581 raise InvalidInput('owner', owner, value)
583 def testContact(user, contact, machine=None):
584 if not re.match("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$", contact, re.I):
585 raise InvalidInput('contact', contact, "Not a valid email")
588 def testDisk(user, disksize, machine=None):
591 def testName(user, name, machine=None):
594 if not Machine.select_by(name=name):
596 if name == machine.name:
598 raise InvalidInput('name', name, "Already taken")
600 def testHostname(user, hostname, machine):
601 for nic in machine.nics:
602 if hostname == nic.hostname:
604 # check if doesn't already exist
605 if NIC.select_by(hostname=hostname):
606 raise InvalidInput('hostname', hostname,
608 if not re.match("^[A-Z0-9-]{1,22}$", hostname, re.I):
609 raise InvalidInput('hostname', hostname, "Not a valid hostname; must only use number, letters, and dashes.")
612 def modify(user, fields):
613 """Handler for modifying attributes of a machine."""
616 transaction = ctx.current.create_transaction()
618 machine = testMachineId(user, fields.getfirst('machine_id'))
619 owner = testOwner(user, fields.getfirst('owner'), machine)
620 contact = testContact(user, fields.getfirst('contact'))
621 hostname = testHostname(owner, fields.getfirst('hostname'),
623 name = testName(user, fields.getfirst('name'), machine)
624 oldname = machine.name
627 memory = fields.getfirst('memory')
628 if memory is not None:
629 memory = validMemory(user, memory, machine, on=False)
630 machine.memory = memory
632 disksize = testDisk(user, fields.getfirst('disk'))
633 if disksize is not None:
634 disksize = validDisk(user, disksize, machine)
635 disk = machine.disks[0]
636 if disk.size != disksize:
637 olddisk[disk.guest_device_name] = disksize
639 ctx.current.save(disk)
641 # XXX first NIC gets hostname on change? Interface doesn't support more.
642 for nic in machine.nics[:1]:
643 nic.hostname = hostname
644 ctx.current.save(nic)
646 if owner is not None and owner != machine.owner:
647 machine.owner = owner
648 if name is not None and name != machine.name:
651 ctx.current.save(machine)
654 transaction.rollback()
656 for diskname in olddisk:
657 remctl("web", "lvresize", oldname, diskname, str(olddisk[diskname]))
658 if name is not None and name != oldname:
659 for disk in machine.disks:
661 remctl("web", "lvrename", oldname, disk.guest_device_name, name)
662 remctl("web", "moveregister", oldname, name)
666 return Template(file="command.tmpl", searchList=[d, global_dict])
669 def help(user, fields):
670 """Handler for help messages."""
671 simple = fields.getfirst('simple')
672 subjects = fields.getlist('subject')
674 mapping = dict(paravm_console="""
675 ParaVM machines do not support console access over VNC. To access
676 these machines, you either need to boot with a liveCD and ssh in or
677 hope that the sipb-xen maintainers add support for serial consoles.""",
679 HVM machines use the virtualization features of the processor, while
680 ParaVM machines use Xen's emulation of virtualization features. You
681 want an HVM virtualized machine.""",
682 cpu_weight="""Don't ask us! We're as mystified as you are.""",
683 owner="""The Owner must be the name of a locker that you are an AFS
684 administrator of. In particular, you or an AFS group you are a member
685 of must have AFS rlidwka bits on the locker. You can check see who
686 administers the LOCKER locker using the command 'fs la /mit/LOCKER' on
694 return Template(file="help.tmpl", searchList=[d, global_dict])
697 def info(user, fields):
698 """Handler for info on a single VM."""
699 machine = testMachineId(user, fields.getfirst('machine_id'))
700 status = statusInfo(machine)
701 has_vnc = hasVnc(status)
703 main_status = dict(name=machine.name,
704 memory=str(machine.memory))
708 main_status = dict(status[1:])
709 start_time = float(main_status.get('start_time', 0))
710 uptime = datetime.timedelta(seconds=int(time.time()-start_time))
711 cpu_time_float = float(main_status.get('cpu_time', 0))
712 cputime = datetime.timedelta(seconds=int(cpu_time_float))
713 display_fields = """name uptime memory state cpu_weight on_reboot
714 on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
715 display_fields = [('name', 'Name'),
717 ('contact', 'Contact'),
720 ('uptime', 'uptime'),
721 ('cputime', 'CPU usage'),
724 ('state', 'state (xen format)'),
725 ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
726 ('on_reboot', 'Action on VM reboot'),
727 ('on_poweroff', 'Action on VM poweroff'),
728 ('on_crash', 'Action on VM crash'),
729 ('on_xend_start', 'Action on Xen start'),
730 ('on_xend_stop', 'Action on Xen stop'),
731 ('bootloader', 'Bootloader options'),
735 machine_info['name'] = machine.name
736 machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
737 machine_info['owner'] = machine.owner
738 machine_info['contact'] = machine.contact
740 nic_fields = getNicInfo(machine_info, machine)
741 nic_point = display_fields.index('NIC_INFO')
742 display_fields = display_fields[:nic_point] + nic_fields + display_fields[nic_point+1:]
744 disk_fields = getDiskInfo(machine_info, machine)
745 disk_point = display_fields.index('DISK_INFO')
746 display_fields = display_fields[:disk_point] + disk_fields + display_fields[disk_point+1:]
748 main_status['memory'] += ' MB'
749 for field, disp in display_fields:
750 if field in ('uptime', 'cputime') and locals()[field] is not None:
751 fields.append((disp, locals()[field]))
752 elif field in machine_info:
753 fields.append((disp, machine_info[field]))
754 elif field in main_status:
755 fields.append((disp, main_status[field]))
758 #fields.append((disp, None))
759 max_mem = maxMemory(user, machine)
760 max_disk = maxDisk(user, machine)
762 cdroms=CDROM.select(),
763 on=status is not None,
770 owner_help=helppopup("owner"),
772 return Template(file='info.tmpl',
773 searchList=[d, global_dict])
775 mapping = dict(list=listVms,
783 if __name__ == '__main__':
784 start_time = time.time()
785 fields = cgi.FieldStorage()
788 email = 'moo@cow.com'
791 if 'SSL_CLIENT_S_DN_Email' in os.environ:
792 username = os.environ[ 'SSL_CLIENT_S_DN_Email'].split("@")[0]
793 u.username = username
794 u.email = os.environ[ 'SSL_CLIENT_S_DN_Email']
798 connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
799 operation = os.environ.get('PATH_INFO', '')
800 # print 'Content-Type: text/plain\n'
803 print "Status: 301 Moved Permanently"
804 print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
807 if operation.startswith('/'):
808 operation = operation[1:]
812 def badOperation(u, e):
813 raise CodeError("Unknown operation")
815 fun = mapping.get(operation, badOperation)
816 if fun not in (help, ):
817 connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
819 output = fun(u, fields)
820 print 'Content-Type: text/html\n'
822 e = sys.stderr.read()
823 sys.stderr=sys.stdout
826 output = output.replace('<body>', '<body><p>STDERR:</p><pre>'+e+'</pre>')
828 except CodeError, err:
829 print 'Content-Type: text/html\n'
831 e = sys.stderr.read()
832 sys.stderr=sys.stdout
833 print error(operation, u, fields, err, e)
834 except InvalidInput, err:
835 print 'Content-Type: text/html\n'
837 e = sys.stderr.read()
838 sys.stderr=sys.stdout
839 print invalidInput(operation, u, fields, err, e)
841 print 'Content-Type: text/plain\n'
843 e = sys.stderr.read()
846 sys.stderr = sys.stdout