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