root / pykota / trunk / bin / cupspykota @ 1390

Revision 1390, 20.0 kB (checked in by jalet, 20 years ago)

Now should output full traceback when crashing

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