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

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

Added some code to display a service message from the xvm team to the unauth page of the website. The message lives in /etc/invirt/message, which would be symlinked to afs

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