ec14236b1e34150df46c6ed863a3fe33d7471a4b
[invirt/packages/invirt-web.git] / code / main.py
1 #!/usr/bin/python
2 """Main CGI script for web interface"""
3
4 import base64
5 import cPickle
6 import cgi
7 import datetime
8 import hmac
9 import os
10 import sha
11 import simplejson
12 import sys
13 import time
14 import urllib
15 from StringIO import StringIO
16
17 def revertStandardError():
18     """Move stderr to stdout, and return the contents of the old stderr."""
19     errio = sys.stderr
20     if not isinstance(errio, StringIO):
21         return None
22     sys.stderr = sys.stdout
23     errio.seek(0)
24     return errio.read()
25
26 def printError():
27     """Revert stderr to stdout, and print the contents of stderr"""
28     if isinstance(sys.stderr, StringIO):
29         print revertStandardError()
30
31 if __name__ == '__main__':
32     import atexit
33     atexit.register(printError)
34     sys.stderr = StringIO()
35
36 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
37
38 import templates
39 from Cheetah.Template import Template
40 import sipb_xen_database
41 from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
42 import validation
43 import cache_acls
44 from webcommon import InvalidInput, CodeError, State
45 import controls
46
47 class Checkpoint:
48     def __init__(self):
49         self.start_time = time.time()
50         self.checkpoints = []
51
52     def checkpoint(self, s):
53         self.checkpoints.append((s, time.time()))
54
55     def __str__(self):
56         return ('Timing info:\n%s\n' %
57                 '\n'.join(['%s: %s' % (d, t - self.start_time) for
58                            (d, t) in self.checkpoints]))
59
60 checkpoint = Checkpoint()
61
62 def jquote(string):
63     return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
64
65 def helppopup(subj):
66     """Return HTML code for a (?) link to a specified help topic"""
67     return ('<span class="helplink"><a href="help?' +
68             cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
69             +'" target="_blank" ' +
70             'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
71
72 def makeErrorPre(old, addition):
73     if addition is None:
74         return
75     if old:
76         return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
77     else:
78         return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
79
80 Template.sipb_xen_database = sipb_xen_database
81 Template.helppopup = staticmethod(helppopup)
82 Template.err = None
83
84 class JsonDict:
85     """Class to store a dictionary that will be converted to JSON"""
86     def __init__(self, **kws):
87         self.data = kws
88         if 'err' in kws:
89             err = kws['err']
90             del kws['err']
91             self.addError(err)
92
93     def __str__(self):
94         return simplejson.dumps(self.data)
95
96     def addError(self, text):
97         """Add stderr text to be displayed on the website."""
98         self.data['err'] = \
99             makeErrorPre(self.data.get('err'), text)
100
101 class Defaults:
102     """Class to store default values for fields."""
103     memory = 256
104     disk = 4.0
105     cdrom = ''
106     autoinstall = ''
107     name = ''
108     type = 'linux-hvm'
109
110     def __init__(self, max_memory=None, max_disk=None, **kws):
111         if max_memory is not None:
112             self.memory = min(self.memory, max_memory)
113         if max_disk is not None:
114             self.max_disk = min(self.disk, max_disk)
115         for key in kws:
116             setattr(self, key, kws[key])
117
118
119
120 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
121
122 def error(op, username, fields, err, emsg):
123     """Print an error page when a CodeError occurs"""
124     d = dict(op=op, user=username, errorMessage=str(err),
125              stderr=emsg)
126     return templates.error(searchList=[d])
127
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])
134
135 def hasVnc(status):
136     """Does the machine with a given status list support VNC?"""
137     if status is None:
138         return False
139     for l in status:
140         if l[0] == 'device' and l[1][0] == 'vfb':
141             d = dict(l[1][1:])
142             return 'location' in d
143     return False
144
145 def parseCreate(username, state, fields):
146     kws = dict([(kw, fields.getfirst(kw)) for kw in 'name owner memory disksize vmtype cdrom clone_from'.split()])
147     validate = validation.Validate(username, state, strict=True, **kws)
148     return dict(contact=username, name=validate.name, memory=validate.memory,
149                 disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
150                 cdrom=getattr(validate, 'cdrom', None),
151                 clone_from=getattr(validate, 'clone_from', None))
152
153 def create(username, state, fields):
154     """Handler for create requests."""
155     try:
156         parsed_fields = parseCreate(username, state, fields)
157         machine = controls.createVm(username, state, **parsed_fields)
158     except InvalidInput, err:
159         pass
160     else:
161         err = None
162     state.clear() #Changed global state
163     d = getListDict(username, state)
164     d['err'] = err
165     if err:
166         for field in fields.keys():
167             setattr(d['defaults'], field, fields.getfirst(field))
168     else:
169         d['new_machine'] = parsed_fields['name']
170     return templates.list(searchList=[d])
171
172
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')
178     on = {}
179     has_vnc = {}
180     xmlist = state.xmlist
181     checkpoint.checkpoint('Got uptimes')
182     can_clone = 'ice3' not in state.xmlist_raw
183     for m in machines:
184         if m not in xmlist:
185             has_vnc[m] = 'Off'
186             m.uptime = None
187         else:
188             m.uptime = xmlist[m]['uptime']
189             if xmlist[m]['console']:
190                 has_vnc[m] = True
191             elif m.type.hvm:
192                 has_vnc[m] = "WTF?"
193             else:
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,
199                         max_disk=max_disk,
200                         owner=username,
201                         cdrom='gutsy-i386')
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,
209              max_disk=max_disk,
210              defaults=defaults,
211              machines=machines,
212              has_vnc=has_vnc,
213              can_clone=can_clone)
214     return d
215
216 def listVms(username, state, 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])
222
223 def vnc(username, state, fields):
224     """VNC applet page.
225
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.
230
231     You might want iptables like:
232
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
239
240     Remember to enable iptables!
241     echo 1 > /proc/sys/net/ipv4/ip_forward
242     """
243     machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
244
245     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
246
247     data = {}
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)
257
258     status = controls.statusInfo(machine)
259     has_vnc = hasVnc(status)
260
261     d = dict(user=username,
262              on=status,
263              has_vnc=has_vnc,
264              machine=machine,
265              hostname=os.environ.get('SERVER_NAME', 'localhost'),
266              authtoken=token)
267     return templates.vnc(searchList=[d])
268
269 def getHostname(nic):
270     """Find the hostname associated with a NIC.
271
272     XXX this should be merged with the similar logic in DNS and DHCP.
273     """
274     if nic.hostname and '.' in nic.hostname:
275         return nic.hostname
276     elif nic.machine:
277         return nic.machine.name + '.xvm.mit.edu'
278     else:
279         return None
280
281
282 def getNicInfo(data_dict, machine):
283     """Helper function for info, get data on nics for a machine.
284
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.
287     """
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'),
292                            ]
293     nic_fields = []
294     for i in range(len(machine.nics)):
295         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
296         if not i:
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]
302     return nic_fields
303
304 def getDiskInfo(data_dict, machine):
305     """Helper function for info, get data on disks for a machine.
306
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.
309     """
310     data_dict['num_disks'] = len(machine.disks)
311     disk_fields_template = [('%s_size', '%s size')]
312     disk_fields = []
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.)
318     return disk_fields
319
320 def command(username, state, fields):
321     """Handler for running commands like boot and delete on a VM."""
322     back = fields.getfirst('back')
323     try:
324         d = controls.commandResult(username, state, fields)
325         if d['command'] == 'Delete VM':
326             back = 'list'
327     except InvalidInput, err:
328         if not back:
329             raise
330         print >> sys.stderr, err
331         result = err
332     else:
333         result = 'Success!'
334         if not back:
335             return templates.command(searchList=[d])
336     if back == 'list':
337         state.clear() #Changed global state
338         d = getListDict(username, state)
339         d['result'] = result
340         return templates.list(searchList=[d])
341     elif back == 'info':
342         machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
343         return ({'Status': '302',
344                  'Location': '/info?machine_id=%d' % machine.machine_id},
345                 "You shouldn't see this message.")
346     else:
347         raise InvalidInput('back', back, 'Not a known back page.')
348
349 def modifyDict(username, state, fields):
350     """Modify a machine as specified by CGI arguments.
351
352     Return a list of local variables for modify.tmpl.
353     """
354     olddisk = {}
355     transaction = ctx.current.create_transaction()
356     try:
357         kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name memory vmtype disksize'.split()])
358         validate = validation.Validate(username, state, **kws)
359         machine = validate.machine
360         oldname = machine.name
361
362         if hasattr(validate, 'memory'):
363             machine.memory = validate.memory
364
365         if hasattr(validate, 'vmtype'):
366             machine.type = validate.vmtype
367
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
373                 disk.size = disksize
374                 ctx.current.save(disk)
375
376         update_acl = False
377         if hasattr(validate, 'owner') and validate.owner != machine.owner:
378             machine.owner = validate.owner
379             update_acl = True
380         if hasattr(validate, 'name'):
381             machine.name = name
382         if hasattr(validate, 'admin') and validate.admin != machine.administrator:
383             machine.administrator = validate.admin
384             update_acl = True
385         if hasattr(validate, 'contact'):
386             machine.contact = validate.contact
387
388         ctx.current.save(machine)
389         if update_acl:
390             print >> sys.stderr, machine, machine.administrator
391             cache_acls.refreshMachine(machine)
392         transaction.commit()
393     except:
394         transaction.rollback()
395         raise
396     for diskname in olddisk:
397         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
398     if hasattr(validate, 'name'):
399         controls.renameMachine(machine, oldname, validate.name)
400     return dict(user=username,
401                 command="modify",
402                 machine=machine)
403
404 def modify(username, state, fields):
405     """Handler for modifying attributes of a machine."""
406     try:
407         modify_dict = modifyDict(username, state, fields)
408     except InvalidInput, err:
409         result = None
410         machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
411     else:
412         machine = modify_dict['machine']
413         result = 'Success!'
414         err = None
415     info_dict = infoDict(username, machine)
416     info_dict['err'] = err
417     if err:
418         for field in fields.keys():
419             setattr(info_dict['defaults'], field, fields.getfirst(field))
420     info_dict['result'] = result
421     return templates.info(searchList=[info_dict])
422
423
424 def helpHandler(username, state, fields):
425     """Handler for help messages."""
426     simple = fields.getfirst('simple')
427     subjects = fields.getlist('subject')
428
429     help_mapping = {'ParaVM Console': """
430 ParaVM machines do not support local console access over VNC.  To
431 access the serial console of these machines, you can SSH with Kerberos
432 to console.xvm.mit.edu, using the name of the machine as your
433 username.""",
434                     'HVM/ParaVM': """
435 HVM machines use the virtualization features of the processor, while
436 ParaVM machines use Xen's emulation of virtualization features.  You
437 want an HVM virtualized machine.""",
438                     'CPU Weight': """
439 Don't ask us!  We're as mystified as you are.""",
440                     'Owner': """
441 The owner field is used to determine <a
442 href="help?subject=Quotas">quotas</a>.  It must be the name of a
443 locker that you are an AFS administrator of.  In particular, you or an
444 AFS group you are a member of must have AFS rlidwka bits on the
445 locker.  You can check who administers the LOCKER locker using the
446 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
447 href="help?subject=Administrator">administrator</a>.""",
448                     'Administrator': """
449 The administrator field determines who can access the console and
450 power on and off the machine.  This can be either a user or a moira
451 group.""",
452                     'Quotas': """
453 Quotas are determined on a per-locker basis.  Each locker may have a
454 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
455 active machines.""",
456                     'Console': """
457 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
458 setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
459 your machine will run just fine, but the applet's display of the
460 console will suffer artifacts.
461 """
462                     }
463
464     if not subjects:
465         subjects = sorted(help_mapping.keys())
466
467     d = dict(user=username,
468              simple=simple,
469              subjects=subjects,
470              mapping=help_mapping)
471
472     return templates.help(searchList=[d])
473
474
475 def badOperation(u, s, e):
476     """Function called when accessing an unknown URI."""
477     raise CodeError("Unknown operation")
478
479 def infoDict(username, state, machine):
480     """Get the variables used by info.tmpl."""
481     status = controls.statusInfo(machine)
482     checkpoint.checkpoint('Getting status info')
483     has_vnc = hasVnc(status)
484     if status is None:
485         main_status = dict(name=machine.name,
486                            memory=str(machine.memory))
487         uptime = None
488         cputime = None
489     else:
490         main_status = dict(status[1:])
491         start_time = float(main_status.get('start_time', 0))
492         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
493         cpu_time_float = float(main_status.get('cpu_time', 0))
494         cputime = datetime.timedelta(seconds=int(cpu_time_float))
495     checkpoint.checkpoint('Status')
496     display_fields = """name uptime memory state cpu_weight on_reboot 
497      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
498     display_fields = [('name', 'Name'),
499                       ('owner', 'Owner'),
500                       ('administrator', 'Administrator'),
501                       ('contact', 'Contact'),
502                       ('type', 'Type'),
503                       'NIC_INFO',
504                       ('uptime', 'uptime'),
505                       ('cputime', 'CPU usage'),
506                       ('memory', 'RAM'),
507                       'DISK_INFO',
508                       ('state', 'state (xen format)'),
509                       ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
510                       ('on_reboot', 'Action on VM reboot'),
511                       ('on_poweroff', 'Action on VM poweroff'),
512                       ('on_crash', 'Action on VM crash'),
513                       ('on_xend_start', 'Action on Xen start'),
514                       ('on_xend_stop', 'Action on Xen stop'),
515                       ('bootloader', 'Bootloader options'),
516                       ]
517     fields = []
518     machine_info = {}
519     machine_info['name'] = machine.name
520     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
521     machine_info['owner'] = machine.owner
522     machine_info['administrator'] = machine.administrator
523     machine_info['contact'] = machine.contact
524
525     nic_fields = getNicInfo(machine_info, machine)
526     nic_point = display_fields.index('NIC_INFO')
527     display_fields = (display_fields[:nic_point] + nic_fields +
528                       display_fields[nic_point+1:])
529
530     disk_fields = getDiskInfo(machine_info, machine)
531     disk_point = display_fields.index('DISK_INFO')
532     display_fields = (display_fields[:disk_point] + disk_fields +
533                       display_fields[disk_point+1:])
534
535     main_status['memory'] += ' MiB'
536     for field, disp in display_fields:
537         if field in ('uptime', 'cputime') and locals()[field] is not None:
538             fields.append((disp, locals()[field]))
539         elif field in machine_info:
540             fields.append((disp, machine_info[field]))
541         elif field in main_status:
542             fields.append((disp, main_status[field]))
543         else:
544             pass
545             #fields.append((disp, None))
546
547     checkpoint.checkpoint('Got fields')
548
549
550     max_mem = validation.maxMemory(machine.owner, state, machine, False)
551     checkpoint.checkpoint('Got mem')
552     max_disk = validation.maxDisk(machine.owner, machine)
553     defaults = Defaults()
554     for name in 'machine_id name administrator owner memory contact'.split():
555         setattr(defaults, name, getattr(machine, name))
556     defaults.type = machine.type.type_id
557     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
558     checkpoint.checkpoint('Got defaults')
559     d = dict(user=username,
560              on=status is not None,
561              machine=machine,
562              defaults=defaults,
563              has_vnc=has_vnc,
564              uptime=str(uptime),
565              ram=machine.memory,
566              max_mem=max_mem,
567              max_disk=max_disk,
568              owner_help=helppopup("Owner"),
569              fields = fields)
570     return d
571
572 def info(username, state, fields):
573     """Handler for info on a single VM."""
574     machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
575     d = infoDict(username, state, machine)
576     checkpoint.checkpoint('Got infodict')
577     return templates.info(searchList=[d])
578
579 def unauthFront(_, _2, fields):
580     """Information for unauth'd users."""
581     return templates.unauth(searchList=[{'simple' : True}])
582
583 mapping = dict(list=listVms,
584                vnc=vnc,
585                command=command,
586                modify=modify,
587                info=info,
588                create=create,
589                help=helpHandler,
590                unauth=unauthFront)
591
592 def printHeaders(headers):
593     """Print a dictionary as HTTP headers."""
594     for key, value in headers.iteritems():
595         print '%s: %s' % (key, value)
596     print
597
598
599 def getUser(environ):
600     """Return the current user based on the SSL environment variables"""
601     email = environ.get('SSL_CLIENT_S_DN_Email', None)
602     if email is None:
603         return None
604     if not email.endswith('@MIT.EDU'):
605         return None
606     return email[:-8]
607
608 class App:
609     def __init__(self, environ, start_response):
610         self.environ = environ
611         self.start = start_response
612
613         self.username = getUser(environ)
614         self.state = State(self.username)
615
616     def __iter__(self):
617         fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
618         print >> sys.stderr, fields
619         operation = self.environ.get('PATH_INFO', '')
620         if not operation:
621             self.start("301 Moved Permanently", [('Location',
622                                                   os.environ['SCRIPT_NAME']+'/')])
623             return
624         if self.username is None:
625             operation = 'unauth'
626         if operation.startswith('/'):
627             operation = operation[1:]
628         if not operation:
629             operation = 'list'
630         print 'Starting', operation
631
632         start_time = time.time()
633         fun = mapping.get(operation, badOperation)
634         try:
635             checkpoint.checkpoint('Before')
636             output = fun(self.username, self.state, fields)
637             checkpoint.checkpoint('After')
638
639             headers = dict(DEFAULT_HEADERS)
640             if isinstance(output, tuple):
641                 new_headers, output = output
642                 headers.update(new_headers)
643             print 'MOO2'
644             e = revertStandardError()
645             if e:
646                 if isinstance(output, basestring):
647                     sys.stderr = StringIO()
648                     x = str(output)
649                     print >> sys.stderr, x
650                     print >> sys.stderr, 'XXX'
651                     print >> sys.stderr, e
652                     raise Exception()
653                 output.addError(e)
654             output_string =  str(output)
655             checkpoint.checkpoint('output as a string')
656         except Exception, err:
657             if not fields.has_key('js'):
658                 if isinstance(err, CodeError):
659                     self.start('500 Internal Server Error', [('Content-Type', 'text/html')])
660                     e = revertStandardError()
661                     s = error(operation, self.username, fields, err, e)
662                     yield str(s)
663                     return
664                 if isinstance(err, InvalidInput):
665                     self.start('200 OK', [('Content-Type', 'text/html')])
666                     e = revertStandardError()
667                     yield str(invalidInput(operation, self.username, fields, err, e))
668                     return
669             self.start('500 Internal Server Error', [('Content-Type', 'text/plain')])
670             import traceback
671             yield '''Uh-oh!  We experienced an error.'
672 Please email xvm-dev@mit.edu with the contents of this page.'
673 ----
674 %s
675 ----
676 %s
677 ----''' % (str(err), traceback.format_exc())
678         self.start('200 OK', headers.items())
679         yield output_string
680         if fields.has_key('timedebug'):
681             yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
682
683 def constructor():
684     connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
685     return App
686
687 def main():
688     from flup.server.fcgi_fork import WSGIServer
689     WSGIServer(constructor()).run()
690
691 if __name__ == '__main__':
692     main()