root / pykota / trunk / bin / cupspykota @ 1375

Revision 1375, 19.9 kB (checked in by jalet, 20 years ago)

PYKOTAPHASE wasn't set soon enough at the start of the job

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