root / pykota / trunk / bin / cupspykota @ 1321

Revision 1321, 18.2 kB (checked in by jalet, 20 years ago)

Fixes stupid software accounting bug in CUPS backend

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