root / pykota / trunk / bin / cupspykota @ 1358

Revision 1335, 18.5 kB (checked in by jalet, 21 years ago)

Should fix the incorrect "backend died abnormally" problem.

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
Line 
1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
4# CUPSPyKota accounting backend
5#
6# PyKota - Print Quotas for CUPS and LPRng
7#
8# (c) 2003-2004 Jerome Alet <alet@librelogiciel.com>
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program; if not, write to the Free Software
21# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
22#
23# $Id$
24#
25# $Log$
26# Revision 1.27  2004/02/04 23:41:27  jalet
27# Should fix the incorrect "backend died abnormally" problem.
28#
29# Revision 1.26  2004/01/30 16:35:03  jalet
30# Fixes stupid software accounting bug in CUPS backend
31#
32# Revision 1.25  2004/01/16 17:51:46  jalet
33# Fuck Fuck Fuck !!!
34#
35# Revision 1.24  2004/01/14 15:52:01  jalet
36# Small fix for job cancelling code.
37#
38# Revision 1.23  2004/01/13 10:48:28  jalet
39# Small streams polling loop modification.
40#
41# Revision 1.22  2004/01/12 22:43:40  jalet
42# New formula to compute a job's price
43#
44# Revision 1.21  2004/01/12 18:17:36  jalet
45# Denied jobs weren't stored into the history anymore, this is now fixed.
46#
47# Revision 1.20  2004/01/11 23:22:42  jalet
48# Major code refactoring, it's way cleaner, and now allows automated addition
49# of printers on first print.
50#
51# Revision 1.19  2004/01/08 14:10:32  jalet
52# Copyright year changed.
53#
54# Revision 1.18  2004/01/07 16:16:32  jalet
55# Better debugging information
56#
57# Revision 1.17  2003/12/27 16:49:25  uid67467
58# Should be ok now.
59#
60# Revision 1.17  2003/12/06 08:54:29  jalet
61# Code simplifications.
62# Added many debugging messages.
63#
64# Revision 1.16  2003/11/26 20:43:29  jalet
65# Inadvertantly introduced a bug, which is fixed.
66#
67# Revision 1.15  2003/11/26 19:17:35  jalet
68# Printing on a printer not present in the Quota Storage now results
69# in the job being stopped or cancelled depending on the system.
70#
71# Revision 1.14  2003/11/25 13:25:45  jalet
72# Boolean problem with old Python, replaced with 0
73#
74# Revision 1.13  2003/11/23 19:01:35  jalet
75# Job price added to history
76#
77# Revision 1.12  2003/11/21 14:28:43  jalet
78# More complete job history.
79#
80# Revision 1.11  2003/11/19 23:19:35  jalet
81# Code refactoring work.
82# Explicit redirection to /dev/null has to be set in external policy now, just
83# like in external mailto.
84#
85# Revision 1.10  2003/11/18 17:54:24  jalet
86# SIGTERMs are now transmitted to original backends.
87#
88# Revision 1.9  2003/11/18 14:11:07  jalet
89# Small fix for bizarre urls
90#
91# Revision 1.8  2003/11/15 14:26:44  jalet
92# General improvements to the documentation.
93# Email address changed in sample configuration file, because
94# I receive low quota messages almost every day...
95#
96# Revision 1.7  2003/11/14 22:05:12  jalet
97# New CUPS backend fully functionnal.
98# Old CUPS configuration method is now officially deprecated.
99#
100# Revision 1.6  2003/11/14 20:13:11  jalet
101# We exit the loop too soon.
102#
103# Revision 1.5  2003/11/14 18:31:27  jalet
104# Not perfect, but seems to work with the poll() loop.
105#
106# Revision 1.4  2003/11/14 17:04:15  jalet
107# More (untested) work on the CUPS backend.
108#
109# Revision 1.3  2003/11/12 23:27:44  jalet
110# More work on new backend. This commit may be unstable.
111#
112# Revision 1.2  2003/11/12 09:33:34  jalet
113# New CUPS backend supports device enumeration
114#
115# Revision 1.1  2003/11/08 16:05:31  jalet
116# CUPS backend added for people to experiment.
117#
118#
119#
120
121import sys
122import os
123import popen2
124import cStringIO
125import shlex
126import select
127import signal
128import time
129
130from pykota.tool import PyKotaFilterOrBackend, PyKotaToolError
131from pykota.config import PyKotaConfigError
132from pykota.storage import PyKotaStorageError
133from pykota.accounter import PyKotaAccounterError
134from pykota.requester import PyKotaRequesterError
135   
136class PyKotaPopen3(popen2.Popen3) :
137    """Our own class to execute real backends.
138   
139       Their first argument is different from their path so using
140       native popen2.Popen3 would not be feasible.
141    """
142    def __init__(self, cmd, capturestderr=0, bufsize=-1, arg0=None) :
143        self.arg0 = arg0
144        popen2.Popen3.__init__(self, cmd, capturestderr, bufsize)
145       
146    def _run_child(self, cmd):
147        for i in range(3, 256): # TODO : MAXFD in original popen2 module
148            try:
149                os.close(i)
150            except OSError:
151                pass
152        try:
153            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
154        finally:
155            os._exit(1)
156   
157class PyKotaBackend(PyKotaFilterOrBackend) :       
158    """A class for the pykota backend."""
159    def __init__(self) :
160        """Does normal initialization then installs signal handler."""
161        # Normal init
162        PyKotaFilterOrBackend.__init__(self)
163       
164        # then deal with signals
165        # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM
166        # Fortunately SIGPIPE is already ignored by Python
167        # It's there just in case this changes in the future.
168        # Here we have to handle SIGTERM correctly, and pass
169        # it to the original backend if needed.
170        self.gotSigTerm = 0
171        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
172        signal.signal(signal.SIGTERM, self.sigterm_handler)
173       
174    def sigterm_handler(self, signum, frame) :
175        """Sets a global variable whenever SIGTERM is received."""
176        # SIGTERM will be ignore most of the time, but during
177        # the call to the real backend, we have to pass it through.
178        self.gotSigTerm = 1
179        self.logger.log_message(_("SIGTERM received, job %s cancelled.") % self.jobid, "info")
180       
181    def acceptJob(self) :       
182        """Returns the appropriate exit code to tell CUPS all is OK."""
183        return 0
184           
185    def removeJob(self) :           
186        """Returns the appropriate exit code to let CUPS think all is OK.
187       
188           Returning 0 (success) prevents CUPS from stopping the print queue.
189        """   
190        return 0
191       
192    def doWork(self, policy, printer, user, userpquota) :   
193        """Most of the work is done here."""
194        # Two different values possible for policy here :
195        # ALLOW means : Either printer, user or user print quota doesn't exist,
196        #               but the job should be allowed anyway.
197        # OK means : Both printer, user and user print quota exist, job should
198        #            be allowed if current user is allowed to print on this printer
199        if policy == "OK" :
200            self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
201            action = self.warnUserPQuota(userpquota)
202            self.logdebug("Job accounting begins.")
203            self.accounter.beginJob(userpquota)
204        else :   
205            action = "ALLOW"
206           
207        # pass the job's data to the real backend   
208        if action in ["ALLOW", "WARN"] :
209            if self.gotSigTerm :
210                retcode = self.removeJob()
211            else :   
212                retcode = self.handleData()       
213        else :       
214            retcode = self.removeJob()
215       
216        if policy == "OK" :       
217            # stops accounting.
218            self.accounter.endJob(userpquota)
219            self.logdebug("Job accounting ends.")
220               
221            # retrieve the job size   
222            if action == "DENY" :
223                jobsize = 0
224                self.logdebug("Job size forced to 0 because printing is denied.")
225            else :   
226                jobsize = self.accounter.getJobSize()
227            self.logdebug("Job size : %i" % jobsize)
228           
229            # update the quota for the current user on this printer
230            self.logdebug("Updating user %s's quota on printer %s" % (user.Name, printer.Name))
231            jobprice = userpquota.increasePagesUsage(jobsize)
232           
233            # adds the current job to history   
234            printer.addJobToHistory(self.jobid, user, self.accounter.getLastPageCounter(), action, jobsize, jobprice, self.preserveinputfile, self.title, self.copies, self.options)
235            self.logdebug("Job added to history.")
236           
237        return retcode   
238                   
239    def handleData(self) :                   
240        """Pass the job's data to the real backend."""
241        # Now it becomes tricky...
242        # We must pass the unmodified job to the original backend
243        # First ensure that we have a file object as input
244        mustclose = 0   
245        if self.inputfile is not None :   
246            if hasattr(self.inputfile, "read") :
247                infile = self.inputfile
248            else :   
249                infile = open(self.inputfile, "rb")
250            mustclose = 1
251        else :   
252            infile = sys.stdin
253           
254        # Find the real backend pathname   
255        realbackend = os.path.join(os.path.split(sys.argv[0])[0], self.originalbackend)
256       
257        # And launch it
258        self.logdebug("Starting real backend %s with args %s" % (realbackend, " ".join(['"%s"' % a for a in ([os.environ["DEVICE_URI"]] + sys.argv[1:])])))
259        subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"])
260       
261        # Save file descriptors, we will need them later.
262        infno = infile.fileno()
263        stdoutfno = sys.stdout.fileno()
264        stderrfno = sys.stderr.fileno()
265        fromcfno = subprocess.fromchild.fileno()
266        tocfno = subprocess.tochild.fileno()
267        cerrfno = subprocess.childerr.fileno()
268       
269        # We will have to be careful when dealing with I/O
270        # So we use a poll object to know when to read or write
271        pollster = select.poll()
272        pollster.register(infno, select.POLLIN | select.POLLPRI)
273        pollster.register(fromcfno, select.POLLIN | select.POLLPRI)
274        pollster.register(cerrfno, select.POLLIN | select.POLLPRI)
275        pollster.register(stdoutfno, select.POLLOUT)
276        pollster.register(stderrfno, select.POLLOUT)
277        pollster.register(tocfno, select.POLLOUT)
278       
279        # Initialize our buffers
280        indata = ""
281        outdata = ""
282        errdata = ""
283        endinput = endoutput = enderr = 0
284        inputclosed = outputclosed = errclosed = 0
285        killed = 0
286        self.logdebug("Entering streams polling loop...")
287        status = -1
288        while status == -1 :
289            # First check if original backend is still alive
290            status = subprocess.poll()
291           
292            # Now if we got SIGTERM, we have
293            # to kill -TERM the original backend
294            if self.gotSigTerm and not killed :
295                try :
296                    os.kill(subprocess.pid, signal.SIGTERM)
297                    self.logger.log_message(_("SIGTERM was sent to real backend %s (pid: %s)") % (realbackend, subprocess.pid), "info")
298                    killed = 1
299                except : # ignore if process was already killed.
300                    pass
301           
302            # In any case, deal with any remaining I/O
303            availablefds = pollster.poll(5000)
304            for (fd, mask) in availablefds :
305                # self.logdebug("file: %i    mask: %04x" % (fd, mask))
306                if mask & select.POLLOUT :
307                    # We can write
308                    if fd == tocfno :
309                        if indata :
310                            os.write(fd, indata)   
311                            indata = ""
312                        if endinput :   
313                            pollster.unregister(tocfno)       
314                            self.logdebug("Closing real backend's stdin.")
315                            os.close(tocfno)
316                            inputclosed = 1
317                    elif fd == stdoutfno :
318                        if outdata :
319                            os.write(fd, outdata)
320                            outdata = ""
321                        if endoutput :   
322                            pollster.unregister(stdoutfno)       
323                            outputclosed = 1
324                    elif fd == stderrfno :
325                        if errdata :
326                            os.write(fd, errdata)
327                            errdata = ""
328                        if enderr :   
329                            pollster.unregister(stderrfno)       
330                            errclosed = 1
331                if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
332                    # We have something to read
333                    data = os.read(fd, 256 * 1024)
334                    if fd == infno :
335                        indata += data
336                        if not data :    # If yes, then no more input data
337                            pollster.unregister(infno)
338                            self.logdebug("Input data ends.")
339                            endinput = 1 # this happens with real files.
340                    elif fd == fromcfno :
341                        outdata += data
342                    elif fd == cerrfno :   
343                        errdata += data
344                if (mask & select.POLLHUP) or (mask & select.POLLERR) :
345                    # I've never seen POLLERR myself, but this probably
346                    # can't hurt to treat an error condition just like
347                    # an EOF.
348                    #
349                    # Some standard I/O stream has no more datas
350                    pollster.unregister(fd)
351                    if fd == infno :
352                        # Here we are in the case where the input file is stdin.
353                        # which has no more data to be read.
354                        self.logdebug("Input data ends.")
355                        endinput = 1
356                    elif fd == fromcfno :   
357                        # This should never happen, since
358                        # CUPS backends don't send anything on their
359                        # standard output.
360                        # We are no more interested in this file descriptor       
361                        self.logdebug("Closing real backend's stdout.")
362                        os.close(fromcfno)
363                        endoutput = 1
364                    elif fd == cerrfno :   
365                        # Original CUPS backend has finished
366                        # to write informations on its standard error.
367                        # We are no more interested in this file descriptor        .
368                        self.logdebug("Closing real backend's stderr.")
369                        os.close(cerrfno)
370                        enderr = 1
371            if killed or (inputclosed and outputclosed and errclosed) :
372                break
373               
374        # We must close the real backend's input stream
375        if killed and not inputclosed :
376            self.logdebug("Forcing close of real backend's stdin.")
377            os.close(tocfno)
378       
379        # Input file was a real file, we have to close it.   
380        if mustclose :
381            infile.close()
382           
383        self.logdebug("Exiting streams polling loop...")
384           
385        # Check exit code of original CUPS backend.   
386        if status == -1 :
387            # we exited the loop before the real backend exited
388            # now we have to wait for it to finish and get its status
389            status = subprocess.wait()
390        if os.WIFEXITED(status) :
391            retcode = os.WEXITSTATUS(status)
392        elif not killed :   
393            self.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error")
394            retcode = -1
395        else :   
396            retcode = self.removeJob()
397        return retcode   
398   
399if __name__ == "__main__" :   
400    # This is a CUPS backend, we should act and die like a CUPS backend
401    if len(sys.argv) == 1 :
402        # we will execute each existing backend in device enumeration mode
403        # and generate their PyKota accounting counterpart
404        (directory, myname) = os.path.split(sys.argv[0])
405        for backend in [os.path.join(directory, b) for b in os.listdir(directory) if os.path.isfile(os.path.join(directory, b)) and (b != myname)] :
406            answer = os.popen(backend, "r")
407            try :
408                devices = [line.strip() for line in answer.readlines()]
409            except :   
410                devices = []
411            status = answer.close()
412            if status is None :
413                for d in devices :
414                    # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
415                    # so we have to decompose it carefully
416                    fdevice = cStringIO.StringIO("%s" % d)
417                    tokenizer = shlex.shlex(fdevice)
418                    tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#"
419                    arguments = []
420                    while 1 :
421                        token = tokenizer.get_token()
422                        if token :
423                            arguments.append(token)
424                        else :
425                            break
426                    fdevice.close()
427                    try :
428                        (devicetype, device, name, fullname) = arguments
429                    except ValueError :   
430                        pass    # ignore this 'bizarre' device
431                    else :   
432                        if name.startswith('"') and name.endswith('"') :
433                            name = name[1:-1]
434                        if fullname.startswith('"') and fullname.endswith('"') :
435                            fullname = fullname[1:-1]
436                        print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname)
437        retcode = 0
438    elif len(sys.argv) not in (6, 7) :   
439        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0])
440        retcode = 1
441    else :   
442        try :
443            # Initializes the backend
444            kotabackend = PyKotaBackend()   
445            retcode = kotabackend.mainWork()
446        except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, PyKotaRequesterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg :
447            sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg)
448            sys.stderr.flush()
449            retcode = 1
450       
451        try :
452            kotabackend.storage.close()
453        except (TypeError, NameError, AttributeError) :   
454            pass
455       
456    sys.exit(retcode)   
Note: See TracBrowser for help on using the browser.