source: trunk/packages/invirt-web/code/main.py @ 2811

Last change on this file since 2811 was 2811, checked in by pweaver, 14 years ago

Fixed some style errors

  • Property svn:executable set to *
File size: 26.0 KB
Line 
1#!/usr/bin/python
2"""Main CGI script for web interface"""
3
4import base64
5import cPickle
6import cgi
7import datetime
8import hmac
9import os
10import random
11import sha
12import sys
13import time
14import urllib
15import socket
16import cherrypy
17from cherrypy import _cperror
18from StringIO import StringIO
19
20def printError():
21    """Revert stderr to stdout, and print the contents of stderr"""
22    if isinstance(sys.stderr, StringIO):
23        print revertStandardError()
24
25if __name__ == '__main__':
26    import atexit
27    atexit.register(printError)
28
29import validation
30import cache_acls
31from webcommon import State
32import controls
33from getafsgroups import getAfsGroupMembers
34from invirt import database
35from invirt.database import Machine, CDROM, session, connect, MachineAccess, Type, Autoinstall
36from invirt.config import structs as config
37from invirt.common import InvalidInput, CodeError
38
39from view import View, revertStandardError
40
41
42static_dir = os.path.join(os.path.dirname(__file__), 'static')
43InvirtStatic = cherrypy.tools.staticdir.handler(
44    root=static_dir,
45    dir=static_dir,
46    section='/static')
47
48class InvirtUnauthWeb(View):
49    static = InvirtStatic
50
51    @cherrypy.expose
52    @cherrypy.tools.mako(filename="/unauth.mako")
53    def index(self):
54        d = dict(simple=True)
55        if os.path.exists("/etc/invirt/message"):
56            f = open('/etc/invirt/message')
57            d['serviceMessage']= f.read()
58        return d
59
60class InvirtWeb(View):
61    def __init__(self):
62        super(self.__class__,self).__init__()
63        connect()
64        self._cp_config['tools.require_login.on'] = True
65        self._cp_config['tools.catch_stderr.on'] = True
66        self._cp_config['tools.mako.imports'] = ['from invirt.config import structs as config',
67                                                 'from invirt import database']
68        self._cp_config['request.error_response'] = self.handle_error
69
70    static = InvirtStatic
71
72    @cherrypy.expose
73    @cherrypy.tools.mako(filename="/invalid.mako")
74    def invalidInput(self):
75        """Print an error page when an InvalidInput exception occurs"""
76        err = cherrypy.request.prev.params["err"]
77        emsg = cherrypy.request.prev.params["emsg"]
78        d = dict(err_field=err.err_field,
79                 err_value=str(err.err_value), stderr=emsg,
80                 errorMessage=str(err))
81        return d
82
83    @cherrypy.expose
84    @cherrypy.tools.mako(filename="/error.mako")
85    def error(self):
86        """Print an error page when an exception occurs"""
87        op = cherrypy.request.prev.path_info
88        username = cherrypy.request.login
89        err = cherrypy.request.prev.params["err"]
90        emsg = cherrypy.request.prev.params["emsg"]
91        traceback = cherrypy.request.prev.params["traceback"]
92        d = dict(op=op, user=username, fields=cherrypy.request.prev.params,
93                 errorMessage=str(err), stderr=emsg, traceback=traceback)
94        error_raw = cherrypy.request.lookup.get_template("/error_raw.mako")
95        details = error_raw.render(**d)
96        exclude = config.web.errormail_exclude
97        if username not in exclude and '*' not in exclude:
98            send_error_mail('xvm error on %s for %s: %s' % (op, cherrypy.request.login, err),
99                            details)
100        d['details'] = details
101        return d
102
103    def __getattr__(self, name):
104        if name in ("admin", "overlord"):
105            if not cherrypy.request.login in getAfsGroupMembers(config.adminacl, config.authz.afs.cells[0].cell):
106                raise InvalidInput('username', cherrypy.request.login,
107                                   'Not in admin group %s.' % config.adminacl)
108            cherrypy.request.state = State(cherrypy.request.login, isadmin=True)
109            return self
110        else:
111            return super(InvirtWeb, self).__getattr__(name)
112
113    def handle_error(self):
114        err = sys.exc_info()[1]
115        if isinstance(err, InvalidInput):
116            cherrypy.request.params['err'] = err
117            cherrypy.request.params['emsg'] = revertStandardError()
118            raise cherrypy.InternalRedirect('/invalidInput')
119        if not cherrypy.request.prev or 'err' not in cherrypy.request.prev.params:
120            cherrypy.request.params['err'] = err
121            cherrypy.request.params['emsg'] = revertStandardError()
122            cherrypy.request.params['traceback'] = _cperror.format_exc()
123            raise cherrypy.InternalRedirect('/error')
124        # fall back to cherrypy default error page
125        cherrypy.HTTPError(500).set_response()
126
127    @cherrypy.expose
128    @cherrypy.tools.mako(filename="/list.mako")
129    def list(self, result=None):
130        """Handler for list requests."""
131        d = getListDict(cherrypy.request.login, cherrypy.request.state)
132        if result is not None:
133            d['result'] = result
134        return d
135    index=list
136
137    @cherrypy.expose
138    @cherrypy.tools.mako(filename="/help.mako")
139    def help(self, subject=None, simple=False):
140        """Handler for help messages."""
141
142        help_mapping = {
143            'Autoinstalls': """
144The autoinstaller builds a minimal Debian or Ubuntu system to run as a
145ParaVM.  You can access the resulting system by logging into the <a
146href="help?simple=true&subject=ParaVM+Console">serial console server</a>
147with your Kerberos tickets; there is no root password so sshd will
148refuse login.</p>
149
150<p>Under the covers, the autoinstaller uses our own patched version of
151xen-create-image, which is a tool based on debootstrap.  If you log
152into the serial console while the install is running, you can watch
153it.
154""",
155            'ParaVM Console': """
156ParaVM machines do not support local console access over VNC.  To
157access the serial console of these machines, you can SSH with Kerberos
158to %s, using the name of the machine as your
159username.""" % config.console.hostname,
160            'HVM/ParaVM': """
161HVM machines use the virtualization features of the processor, while
162ParaVM machines rely on a modified kernel to communicate directly with
163the hypervisor.  HVMs support boot CDs of any operating system, and
164the VNC console applet.  The three-minute autoinstaller produces
165ParaVMs.  ParaVMs typically are more efficient, and always support the
166<a href="help?subject=ParaVM+Console">console server</a>.</p>
167
168<p>More details are <a
169href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
170wiki</a>, including steps to prepare an HVM guest to boot as a ParaVM
171(which you can skip by using the autoinstaller to begin with.)</p>
172
173<p>We recommend using a ParaVM when possible and an HVM when necessary.
174""",
175            'CPU Weight': """
176Don't ask us!  We're as mystified as you are.""",
177            'Owner': """
178The owner field is used to determine <a
179href="help?subject=Quotas">quotas</a>.  It must be the name of a
180locker that you are an AFS administrator of.  In particular, you or an
181AFS group you are a member of must have AFS rlidwka bits on the
182locker.  You can check who administers the LOCKER locker using the
183commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
184href="help?subject=Administrator">administrator</a>.""",
185            'Administrator': """
186The administrator field determines who can access the console and
187power on and off the machine.  This can be either a user or a moira
188group.""",
189            'Quotas': """
190Quotas are determined on a per-locker basis.  Each locker may have a
191maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
192active machines.""",
193            'Console': """
194<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
195setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
196your machine will run just fine, but the applet's display of the
197console will suffer artifacts.
198""",
199            'Windows': """
200<strong>Windows Vista:</strong> The Vista image is licensed for all MIT students and will automatically activate off the network; see <a href="/static/msca-email.txt">the licensing confirmation e-mail</a> for details. The installer requires 512 MiB RAM and at least 7.5 GiB disk space (15 GiB or more recommended).<br>
201<strong>Windows XP:</strong> This is the volume license CD image. You will need your own volume license key to complete the install. We do not have these available for the general MIT community; ask your department if they have one, or visit <a href="http://msca.mit.edu/">http://msca.mit.edu/</a> if you are staff/faculty to request one.
202"""
203            }
204
205        if not subject:
206            subject = sorted(help_mapping.keys())
207        if not isinstance(subject, list):
208            subject = [subject]
209
210        return dict(simple=simple,
211                    subjects=subject,
212                    mapping=help_mapping)
213    help._cp_config['tools.require_login.on'] = False
214
215    def parseCreate(self, fields):
216        kws = dict([(kw, fields[kw]) for kw in
217         'name description owner memory disksize vmtype cdrom autoinstall'.split()
218                    if fields[kw]])
219        validate = validation.Validate(cherrypy.request.login,
220                                       cherrypy.request.state,
221                                       strict=True, **kws)
222        return dict(contact=cherrypy.request.login, name=validate.name,
223                    description=validate.description, memory=validate.memory,
224                    disksize=validate.disksize, owner=validate.owner,
225                    machine_type=getattr(validate, 'vmtype', Defaults.type),
226                    cdrom=getattr(validate, 'cdrom', None),
227                    autoinstall=getattr(validate, 'autoinstall', None))
228
229    @cherrypy.expose
230    @cherrypy.tools.mako(filename="/list.mako")
231    @cherrypy.tools.require_POST()
232    def create(self, **fields):
233        """Handler for create requests."""
234        try:
235            parsed_fields = self.parseCreate(fields)
236            machine = controls.createVm(cherrypy.request.login,
237                                        cherrypy.request.state, **parsed_fields)
238        except InvalidInput, err:
239            pass
240        else:
241            err = None
242        cherrypy.request.state.clear() #Changed global state
243        d = getListDict(cherrypy.request.login, cherrypy.request.state)
244        d['err'] = err
245        if err:
246            for field, value in fields.items():
247                setattr(d['defaults'], field, value)
248        else:
249            d['new_machine'] = parsed_fields['name']
250        return d
251
252    @cherrypy.expose
253    @cherrypy.tools.mako(filename="/helloworld.mako")
254    def helloworld(self, **kwargs):
255        return {'request': cherrypy.request, 'kwargs': kwargs}
256    helloworld._cp_config['tools.require_login.on'] = False
257
258    @cherrypy.expose
259    def errortest(self):
260        """Throw an error, to test the error-tracing mechanisms."""
261        print >>sys.stderr, "look ma, it's a stderr"
262        raise RuntimeError("test of the emergency broadcast system")
263
264    class MachineView(View):
265        def __getattr__(self, name):
266            """Synthesize attributes to allow RESTful URLs like
267            /machine/13/info. This is hairy. CherryPy 3.2 adds a
268            method called _cp_dispatch that allows you to explicitly
269            handle URLs that can't be mapped, and it allows you to
270            rewrite the path components and continue processing.
271
272            This function gets the next path component being resolved
273            as a string. _cp_dispatch will get an array of strings
274            representing any subsequent path components as well."""
275
276            try:
277                cherrypy.request.params['machine_id'] = int(name)
278                return self
279            except ValueError:
280                return None
281
282        @cherrypy.expose
283        @cherrypy.tools.mako(filename="/info.mako")
284        def info(self, machine_id):
285            """Handler for info on a single VM."""
286            machine = validation.Validate(cherrypy.request.login,
287                                          cherrypy.request.state,
288                                          machine_id=machine_id).machine
289            d = infoDict(cherrypy.request.login, cherrypy.request.state, machine)
290            return d
291        index = info
292
293        @cherrypy.expose
294        @cherrypy.tools.mako(filename="/info.mako")
295        @cherrypy.tools.require_POST()
296        def modify(self, machine_id, **fields):
297            """Handler for modifying attributes of a machine."""
298            try:
299                modify_dict = modifyDict(cherrypy.request.login,
300                                         cherrypy.request.state,
301                                         machine_id, fields)
302            except InvalidInput, err:
303                result = None
304                machine = validation.Validate(cherrypy.request.login,
305                                              cherrypy.request.state,
306                                              machine_id=machine_id).machine
307            else:
308                machine = modify_dict['machine']
309                result = 'Success!'
310                err = None
311            info_dict = infoDict(cherrypy.request.login,
312                                 cherrypy.request.state, machine)
313            info_dict['err'] = err
314            if err:
315                for field, value in fields.items():
316                    setattr(info_dict['defaults'], field, value)
317            info_dict['result'] = result
318            return info_dict
319
320        @cherrypy.expose
321        @cherrypy.tools.mako(filename="/vnc.mako")
322        def vnc(self, machine_id):
323            """VNC applet page.
324
325            Note that due to same-domain restrictions, the applet connects to
326            the webserver, which needs to forward those requests to the xen
327            server.  The Xen server runs another proxy that (1) authenticates
328            and (2) finds the correct port for the VM.
329
330            You might want iptables like:
331
332            -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
333            --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
334            -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
335            --dport 10003 -j SNAT --to-source 18.187.7.142
336            -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
337            --dport 10003 -j ACCEPT
338
339            Remember to enable iptables!
340            echo 1 > /proc/sys/net/ipv4/ip_forward
341            """
342            machine = validation.Validate(cherrypy.request.login,
343                                          cherrypy.request.state,
344                                          machine_id=machine_id).machine
345            token = controls.vnctoken(machine)
346            host = controls.listHost(machine)
347            if host:
348                port = 10003 + [h.hostname for h in config.hosts].index(host)
349            else:
350                port = 5900 # dummy
351
352            status = controls.statusInfo(machine)
353            has_vnc = hasVnc(status)
354
355            d = dict(on=status,
356                     has_vnc=has_vnc,
357                     machine=machine,
358                     hostname=cherrypy.request.local.name,
359                     port=port,
360                     authtoken=token)
361            return d
362
363        @cherrypy.expose
364        @cherrypy.tools.mako(filename="/command.mako")
365        @cherrypy.tools.require_POST()
366        def command(self, command_name, machine_id, **kwargs):
367            """Handler for running commands like boot and delete on a VM."""
368            back = kwargs.get('back')
369            if command_name == 'delete':
370                back = 'list'
371            try:
372                d = controls.commandResult(cherrypy.request.login,
373                                           cherrypy.request.state,
374                                           command_name, machine_id, kwargs)
375            except InvalidInput, err:
376                if not back:
377                    raise
378                print >> sys.stderr, err
379                result = str(err)
380            else:
381                result = 'Success!'
382                if not back:
383                    return d
384            if back == 'list':
385                cherrypy.request.state.clear() #Changed global state
386                raise cherrypy.InternalRedirect('/list?result=%s'
387                                                % urllib.quote(result))
388            elif back == 'info':
389                raise cherrypy.HTTPRedirect(cherrypy.request.base
390                                            + '/machine/%d/' % machine_id,
391                                            status=303)
392            else:
393                raise InvalidInput('back', back, 'Not a known back page.')
394
395    machine = MachineView()
396
397
398class Defaults:
399    """Class to store default values for fields."""
400    memory = 256
401    disk = 4.0
402    cdrom = ''
403    autoinstall = ''
404    name = ''
405    description = ''
406    administrator = ''
407    type = 'linux-hvm'
408
409    def __init__(self, max_memory=None, max_disk=None, **kws):
410        if max_memory is not None:
411            self.memory = min(self.memory, max_memory)
412        if max_disk is not None:
413            self.disk = min(self.disk, max_disk)
414        for key in kws:
415            setattr(self, key, kws[key])
416
417def hasVnc(status):
418    """Does the machine with a given status list support VNC?"""
419    if status is None:
420        return False
421    for l in status:
422        if l[0] == 'device' and l[1][0] == 'vfb':
423            d = dict(l[1][1:])
424            return 'location' in d
425    return False
426
427
428def getListDict(username, state):
429    """Gets the list of local variables used by list.tmpl."""
430    machines = state.machines
431    on = {}
432    has_vnc = {}
433    installing = {}
434    xmlist = state.xmlist
435    for m in machines:
436        if m not in xmlist:
437            has_vnc[m] = 'Off'
438            m.uptime = None
439        else:
440            m.uptime = xmlist[m]['uptime']
441            installing[m] = bool(xmlist[m].get('autoinstall'))
442            if xmlist[m]['console']:
443                has_vnc[m] = True
444            elif m.type.hvm:
445                has_vnc[m] = "WTF?"
446            else:
447                has_vnc[m] = "ParaVM"
448    max_memory = validation.maxMemory(username, state)
449    max_disk = validation.maxDisk(username)
450    defaults = Defaults(max_memory=max_memory,
451                        max_disk=max_disk,
452                        owner=username)
453    def sortkey(machine):
454        return (machine.owner != username, machine.owner, machine.name)
455    machines = sorted(machines, key=sortkey)
456    d = dict(user=username,
457             cant_add_vm=validation.cantAddVm(username, state),
458             max_memory=max_memory,
459             max_disk=max_disk,
460             defaults=defaults,
461             machines=machines,
462             has_vnc=has_vnc,
463             installing=installing)
464    return d
465
466def getHostname(nic):
467    """Find the hostname associated with a NIC.
468
469    XXX this should be merged with the similar logic in DNS and DHCP.
470    """
471    if nic.hostname:
472        hostname = nic.hostname
473    elif nic.machine:
474        hostname = nic.machine.name
475    else:
476        return None
477    if '.' in hostname:
478        return hostname
479    else:
480        return hostname + '.' + config.dns.domains[0]
481
482def getNicInfo(data_dict, machine):
483    """Helper function for info, get data on nics for a machine.
484
485    Modifies data_dict to include the relevant data, and returns a list
486    of (key, name) pairs to display "name: data_dict[key]" to the user.
487    """
488    data_dict['num_nics'] = len(machine.nics)
489    nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
490                           ('nic%s_mac', 'NIC %s MAC Addr'),
491                           ('nic%s_ip', 'NIC %s IP'),
492                           ]
493    nic_fields = []
494    for i in range(len(machine.nics)):
495        nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
496        data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
497        data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
498        data_dict['nic%s_ip' % i] = machine.nics[i].ip
499    if len(machine.nics) == 1:
500        nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
501    return nic_fields
502
503def getDiskInfo(data_dict, machine):
504    """Helper function for info, get data on disks for a machine.
505
506    Modifies data_dict to include the relevant data, and returns a list
507    of (key, name) pairs to display "name: data_dict[key]" to the user.
508    """
509    data_dict['num_disks'] = len(machine.disks)
510    disk_fields_template = [('%s_size', '%s size')]
511    disk_fields = []
512    for disk in machine.disks:
513        name = disk.guest_device_name
514        disk_fields.extend([(x % name, y % name) for x, y in
515                            disk_fields_template])
516        data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
517    return disk_fields
518
519def modifyDict(username, state, machine_id, fields):
520    """Modify a machine as specified by CGI arguments.
521
522    Return a dict containing the machine that was modified.
523    """
524    olddisk = {}
525    session.begin()
526    try:
527        kws = dict([(kw, fields[kw]) for kw in
528         'owner admin contact name description memory vmtype disksize'.split()
529                    if fields[kw]])
530        kws['machine_id'] = machine_id
531        validate = validation.Validate(username, state, **kws)
532        machine = validate.machine
533        oldname = machine.name
534
535        if hasattr(validate, 'memory'):
536            machine.memory = validate.memory
537
538        if hasattr(validate, 'vmtype'):
539            machine.type = validate.vmtype
540
541        if hasattr(validate, 'disksize'):
542            disksize = validate.disksize
543            disk = machine.disks[0]
544            if disk.size != disksize:
545                olddisk[disk.guest_device_name] = disksize
546                disk.size = disksize
547                session.save_or_update(disk)
548
549        update_acl = False
550        if hasattr(validate, 'owner') and validate.owner != machine.owner:
551            machine.owner = validate.owner
552            update_acl = True
553        if hasattr(validate, 'name'):
554            machine.name = validate.name
555            for n in machine.nics:
556                if n.hostname == oldname:
557                    n.hostname = validate.name
558        if hasattr(validate, 'description'):
559            machine.description = validate.description
560        if hasattr(validate, 'admin') and validate.admin != machine.administrator:
561            machine.administrator = validate.admin
562            update_acl = True
563        if hasattr(validate, 'contact'):
564            machine.contact = validate.contact
565
566        session.save_or_update(machine)
567        if update_acl:
568            cache_acls.refreshMachine(machine)
569        session.commit()
570    except:
571        session.rollback()
572        raise
573    for diskname in olddisk:
574        controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
575    if hasattr(validate, 'name'):
576        controls.renameMachine(machine, oldname, validate.name)
577    return dict(machine=machine)
578
579def infoDict(username, state, machine):
580    """Get the variables used by info.tmpl."""
581    status = controls.statusInfo(machine)
582    has_vnc = hasVnc(status)
583    if status is None:
584        main_status = dict(name=machine.name,
585                           memory=str(machine.memory))
586        uptime = None
587        cputime = None
588    else:
589        main_status = dict(status[1:])
590        main_status['host'] = controls.listHost(machine)
591        start_time = float(main_status.get('start_time', 0))
592        uptime = datetime.timedelta(seconds=int(time.time()-start_time))
593        cpu_time_float = float(main_status.get('cpu_time', 0))
594        cputime = datetime.timedelta(seconds=int(cpu_time_float))
595    display_fields = [('name', 'Name'),
596                      ('description', 'Description'),
597                      ('owner', 'Owner'),
598                      ('administrator', 'Administrator'),
599                      ('contact', 'Contact'),
600                      ('type', 'Type'),
601                      'NIC_INFO',
602                      ('uptime', 'uptime'),
603                      ('cputime', 'CPU usage'),
604                      ('host', 'Hosted on'),
605                      ('memory', 'RAM'),
606                      'DISK_INFO',
607                      ('state', 'state (xen format)'),
608                      ]
609    fields = []
610    machine_info = {}
611    machine_info['name'] = machine.name
612    machine_info['description'] = machine.description
613    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
614    machine_info['owner'] = machine.owner
615    machine_info['administrator'] = machine.administrator
616    machine_info['contact'] = machine.contact
617
618    nic_fields = getNicInfo(machine_info, machine)
619    nic_point = display_fields.index('NIC_INFO')
620    display_fields = (display_fields[:nic_point] + nic_fields +
621                      display_fields[nic_point+1:])
622
623    disk_fields = getDiskInfo(machine_info, machine)
624    disk_point = display_fields.index('DISK_INFO')
625    display_fields = (display_fields[:disk_point] + disk_fields +
626                      display_fields[disk_point+1:])
627
628    main_status['memory'] += ' MiB'
629    for field, disp in display_fields:
630        if field in ('uptime', 'cputime') and locals()[field] is not None:
631            fields.append((disp, locals()[field]))
632        elif field in machine_info:
633            fields.append((disp, machine_info[field]))
634        elif field in main_status:
635            fields.append((disp, main_status[field]))
636        else:
637            pass
638            #fields.append((disp, None))
639
640    max_mem = validation.maxMemory(machine.owner, state, machine, False)
641    max_disk = validation.maxDisk(machine.owner, machine)
642    defaults = Defaults()
643    for name in 'machine_id name description administrator owner memory contact'.split():
644        if getattr(machine, name):
645            setattr(defaults, name, getattr(machine, name))
646    defaults.type = machine.type.type_id
647    defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
648    d = dict(user=username,
649             on=status is not None,
650             machine=machine,
651             defaults=defaults,
652             has_vnc=has_vnc,
653             uptime=str(uptime),
654             ram=machine.memory,
655             max_mem=max_mem,
656             max_disk=max_disk,
657             fields = fields)
658    return d
659
660def send_error_mail(subject, body):
661    import subprocess
662
663    to = config.web.errormail
664    mail = """To: %s
665From: root@%s
666Subject: %s
667
668%s
669""" % (to, config.web.hostname, subject, body)
670    p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
671                         stdin=subprocess.PIPE)
672    p.stdin.write(mail)
673    p.stdin.close()
674    p.wait()
675
676random.seed() #sigh
Note: See TracBrowser for help on using the repository browser.