root / pykota / trunk / bin / cupspykota @ 1191

Revision 1191, 20.7 kB (checked in by jalet, 20 years ago)

SIGTERMs are now transmitted to original backends.

  • 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 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.10  2003/11/18 17:54:24  jalet
27# SIGTERMs are now transmitted to original backends.
28#
29# Revision 1.9  2003/11/18 14:11:07  jalet
30# Small fix for bizarre urls
31#
32# Revision 1.8  2003/11/15 14:26:44  jalet
33# General improvements to the documentation.
34# Email address changed in sample configuration file, because
35# I receive low quota messages almost every day...
36#
37# Revision 1.7  2003/11/14 22:05:12  jalet
38# New CUPS backend fully functionnal.
39# Old CUPS configuration method is now officially deprecated.
40#
41# Revision 1.6  2003/11/14 20:13:11  jalet
42# We exit the loop too soon.
43#
44# Revision 1.5  2003/11/14 18:31:27  jalet
45# Not perfect, but seems to work with the poll() loop.
46#
47# Revision 1.4  2003/11/14 17:04:15  jalet
48# More (untested) work on the CUPS backend.
49#
50# Revision 1.3  2003/11/12 23:27:44  jalet
51# More work on new backend. This commit may be unstable.
52#
53# Revision 1.2  2003/11/12 09:33:34  jalet
54# New CUPS backend supports device enumeration
55#
56# Revision 1.1  2003/11/08 16:05:31  jalet
57# CUPS backend added for people to experiment.
58#
59#
60#
61
62import sys
63import os
64import popen2
65import time
66import cStringIO
67import shlex
68import select
69import signal
70
71from pykota.tool import PyKotaTool, PyKotaToolError
72from pykota.config import PyKotaConfigError
73from pykota.storage import PyKotaStorageError
74from pykota.accounter import openAccounter, PyKotaAccounterError
75from pykota.requester import openRequester, PyKotaRequesterError
76
77class PyKotaPopen3(popen2.Popen3) :
78    """Our own class to execute real backends.
79   
80       Their first argument is different from their path so using
81       native popen2.Popen3 would not be feasible.
82    """
83    def __init__(self, cmd, capturestderr=False, bufsize=-1, arg0=None) :
84        self.arg0 = arg0
85        popen2.Popen3.__init__(self, cmd, capturestderr, bufsize)
86       
87    def _run_child(self, cmd):
88        for i in range(3, 256): # TODO : MAXFD in original popen2 module
89            try:
90                os.close(i)
91            except OSError:
92                pass
93        try:
94            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
95        finally:
96            os._exit(1)
97   
98class PyKotaBackend(PyKotaTool) :   
99    """Class for the PyKota backend."""
100    def __init__(self) :
101        PyKotaTool.__init__(self)
102        (self.printingsystem, \
103         self.printerhostname, \
104         self.printername, \
105         self.username, \
106         self.jobid, \
107         self.inputfile, \
108         self.copies, \
109         self.title, \
110         self.options, \
111         self.originalbackend) = self.extractCUPSInfo()
112        self.accounter = openAccounter(self)
113   
114    def extractCUPSInfo(self) :   
115        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
116       
117           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
118        """
119        # Try to detect CUPS
120        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
121            if len(sys.argv) == 7 :
122                inputfile = sys.argv[6]
123            else :   
124                inputfile = None
125               
126            # the DEVICE_URI environment variable's value is
127            # prefixed with "cupspykota:" otherwise we wouldn't
128            # be called. We have to remove this from the environment
129            # before launching the real backend.
130            fulldevice_uri = os.environ.get("DEVICE_URI", "")
131            device_uri = fulldevice_uri[len("cupspykota:"):]
132            if device_uri.startswith("//") :    # lpd (at least)
133                device_uri = device_uri[2:]
134            os.environ["DEVICE_URI"] = device_uri
135            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
136            try :
137                (backend, destination) = device_uri.split(":", 1) 
138            except ValueError :   
139                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
140            while destination.startswith("/") :
141                destination = destination[1:]
142            printerhostname = destination.split("/")[0].split(":")[0]
143            return ("CUPS", \
144                    printerhostname, \
145                    os.environ.get("PRINTER"), \
146                    sys.argv[2].strip(), \
147                    sys.argv[1].strip(), \
148                    inputfile, \
149                    int(sys.argv[4].strip()), \
150                    sys.argv[3], \
151                    sys.argv[5], \
152                    backend)
153        else :   
154            self.logger.log_message(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
155            return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system         
156       
157def format_commandline(prt, usr, cmdline) :           
158    """Passes printer and user names on the command line."""
159    printer = prt.Name
160    user = usr.Name
161    # we don't want the external command's standard
162    # output to break the print job's data, but we
163    # want to keep its standard error
164    return "%s >/dev/null" % (cmdline % locals())
165
166gotSigTerm = 0
167def sigterm_handler(signum, frame) :
168    """Sets a global variable whenever SIGTERM is received."""
169    # SIGTERM will be ignore most of the time, but during
170    # the call to the real backend, we have to pass it through.
171    global gotSigTerm
172    gotSigTerm = 1
173   
174def main(thebackend) :   
175    """Do it, and do it right !"""
176    # first deal with signals
177    # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM
178    # Fortunately SIGPIPE is already ignored by Python
179    # It's there just in case this changes in the future.
180    # Here we have to handle SIGTERM correctly, and pass
181    # it to the original backend if needed.
182    global gotSigTerm
183    gotSigTerm = 0
184    signal.signal(signal.SIGPIPE, signal.SIG_IGN)
185    signal.signal(signal.SIGTERM, sigterm_handler)
186   
187    #
188    # retrieve some informations on the current printer and user
189    # from the Quota Storage.
190    printer = thebackend.storage.getPrinter(thebackend.printername)
191    if not printer.Exists :
192        # The printer is unknown from the Quota Storage perspective
193        # we let the job pass through, but log a warning message
194        thebackend.logger.log_message(_("Printer %s not registered in the PyKota system") % thebackend.printername, "warn")
195        action = "ALLOW"
196    else :   
197        for dummy in range(2) :
198            user = thebackend.storage.getUser(thebackend.username)
199            if user.Exists :
200                break
201            else :   
202                # The user is unknown from the Quota Storage perspective
203                # Depending on the default policy for this printer, we
204                # either let the job pass through or reject it, but we
205                # log a message in any case.
206                (policy, args) = thebackend.config.getPrinterPolicy(thebackend.printername)
207                if policy == "ALLOW" :
208                    action = "POLICY_ALLOW"
209                elif policy == "EXTERNAL" :   
210                    commandline = format_commandline(printer, user, args)
211                    thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (thebackend.username, commandline, thebackend.printername), "info")
212                    if os.system(commandline) :
213                        # if an error occured, we die without error,
214                        # so that the job doesn't stop the print queue.
215                        thebackend.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, thebackend.printername), "error")
216                        return 0
217                    else :   
218                        # here we try a second time, because the goal
219                        # of the external action was to add the user
220                        # in the database.
221                        continue 
222                else :   
223                    action = "POLICY_DENY"
224                thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (thebackend.username, action, thebackend.printername), "warn")
225                if action == "POLICY_DENY" :
226                    # if not allowed to print then die, else proceed.
227                    # we die without error, so that the job doesn't
228                    # stop the print queue.
229                    return 0
230                # when we get there, the printer policy allows the job to pass
231                break   
232                   
233        if user.Exists :
234            # Is the current user allowed to print at all ?
235            action = thebackend.warnUserPQuota(thebackend.storage.getUserPQuota(user, printer))
236        elif policy == "EXTERNAL" :               
237            # if the extenal policy produced no error, but the
238            # user still doesn't exist, we die without error,
239            # so that the job doesn't stop the print queue.
240            thebackend.logger.log_message(_("External policy %s for printer %s couldn't add user %s. Job rejected.") % (commandline, thebackend.printername, thebackend.username), "error")
241            return 0
242       
243    if action not in ["ALLOW", "WARN"] :   
244        # if not allowed to print then die, else proceed.
245        # we die without error, so that the job doesn't
246        # stop the print queue.
247        retcode = 0
248    else :
249        # pass the job untouched to the underlying layer
250        # and starts accounting at the same time
251        thebackend.accounter.beginJob(printer, user)
252       
253        # Now it becomes tricky...
254       
255        # First ensure that we have a file object as input
256        mustclose = 0   
257        if thebackend.inputfile is not None :   
258            if hasattr(thebackend.inputfile, "read") :
259                infile = thebackend.inputfile
260            else :   
261                infile = open(thebackend.inputfile, "rb")
262            mustclose = 1
263        else :   
264            infile = sys.stdin
265           
266        # Find the real backend pathname   
267        realbackend = os.path.join(os.path.split(sys.argv[0])[0], thebackend.originalbackend)
268       
269        # And launch it
270        subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"])
271       
272        # Save file descriptors, we will need them later.
273        infno = infile.fileno()
274        stdoutfno = sys.stdout.fileno()
275        stderrfno = sys.stderr.fileno()
276        fromcfno = subprocess.fromchild.fileno()
277        tocfno = subprocess.tochild.fileno()
278        cerrfno = subprocess.childerr.fileno()
279       
280        # We will have to be careful when dealing with I/O
281        # So we use a poll object to know when to read or write
282        pollster = select.poll()
283        pollster.register(infno, select.POLLIN | select.POLLPRI)
284        pollster.register(fromcfno, select.POLLIN | select.POLLPRI)
285        pollster.register(cerrfno, select.POLLIN | select.POLLPRI)
286        pollster.register(stdoutfno, select.POLLOUT)
287        pollster.register(stderrfno, select.POLLOUT)
288        pollster.register(tocfno, select.POLLOUT)
289       
290        # Initialize our buffers
291        indata = ""
292        outdata = ""
293        errdata = ""
294        endinput = 0
295        status = -1
296        while status == -1 :
297            # First check if original backend is still alive
298            status = subprocess.poll()
299           
300            # Now if we got SIGTERM, we have
301            # to kill -TERM the original backend
302            if gotSigTerm :
303                try :
304                    os.kill(subprocess.pid, signal.SIGTERM)
305                except : # ignore if process was already killed.
306                    pass
307           
308            # In any case, deal with any remaining I/O
309            availablefds = pollster.poll()
310            for (fd, mask) in availablefds :
311                if mask & select.POLLOUT :
312                    # We can write
313                    if fd == tocfno :
314                        if indata :
315                            os.write(fd, indata)   
316                            indata = ""
317                    elif fd == stdoutfno :
318                        if outdata :
319                            os.write(fd, outdata)
320                            outdata = ""
321                    elif fd == stderrfno :
322                        if errdata :
323                            os.write(fd, errdata)
324                            errdata = ""
325                if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
326                    # We have something to read
327                    data = os.read(fd, 256 * 1024)
328                    if fd == infno :
329                        indata += data
330                        if not data :    # If yes, then no more input data
331                            endinput = 1 # this happens with real files.
332                    elif fd == fromcfno :
333                        outdata += data
334                    elif fd == cerrfno :   
335                        errdata += data
336                if (mask & select.POLLHUP) or (mask & select.POLLERR) :
337                    # I've never seen POLLERR myself, but this probably
338                    # can't hurt to treat an error condition just like
339                    # an EOF.
340                    #
341                    # Some standard I/O stream has no more datas
342                    if fd == infno :
343                        # Here we are in the case where the input file is stdin.
344                        # which has no more data to be read.
345                        endinput = 1
346                    elif fd == fromcfno :   
347                        # This should never happen, since
348                        # CUPS backends don't send anything on their
349                        # standard output
350                        if outdata :               
351                            try :
352                                os.write(stdoutfno, outdata)
353                                outdata = ""
354                            except :   
355                                pass
356                        try :       
357                            pollster.unregister(fromcfno)       
358                        except KeyError :   
359                            pass
360                        else :   
361                            os.close(fromcfno)
362                    elif fd == cerrfno :   
363                        # Original CUPS backend has finished
364                        # to write informations on its standard error
365                        if errdata :               
366                            # Try to write remaining info (normally "Ready to print.")
367                            try :
368                                os.write(stderrfno, errdata)
369                                errdata = ""
370                            except :   
371                                pass
372                        # We are no more interested in this file descriptor       
373                        try :       
374                            pollster.unregister(cerrfno)       
375                        except KeyError :   
376                            pass
377                        else :   
378                            os.close(cerrfno)
379                       
380            if endinput :           
381                # We deal with remaining input datas here
382                # because EOF can happen in two different
383                # situations and I don't want to duplicate
384                # code, nor making functions.
385                if indata :               
386                    try :
387                        os.write(tocfno, indata)
388                        indata = ""
389                    except :   
390                        pass
391                # Again, we're not interested in this file descriptor       
392                # anymore.
393                try :       
394                    pollster.unregister(tocfno)       
395                except KeyError :   
396                    pass
397                else :   
398                    os.close(tocfno)
399               
400        # Input file was a real file, we have to close it.   
401        if mustclose :
402            infile.close()
403           
404        # Check exit code of original CUPS backend.   
405        if os.WIFEXITED(status) :
406            retcode = os.WEXITSTATUS(status)
407        else :   
408            thebackend.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error")
409            retcode = -1
410   
411    # stops accounting.
412    thebackend.accounter.endJob(printer, user)
413       
414    # retrieve the job size   
415    jobsize = thebackend.accounter.getJobSize()
416   
417    # update the quota for the current user on this printer
418    if printer.Exists :
419        if jobsize :
420            userquota = thebackend.storage.getUserPQuota(user, printer)
421            if userquota.Exists :
422                userquota.increasePagesUsage(jobsize)
423       
424        # adds the current job to history   
425        printer.addJobToHistory(thebackend.jobid, user, thebackend.accounter.getLastPageCounter(), action, jobsize)
426   
427    return retcode # return (retcode or gotSigTerm) shouldn't be needed
428
429if __name__ == "__main__" :   
430    # This is a CUPS backend, we should act and die like a CUPS backend
431    if len(sys.argv) == 1 :
432        # we will execute each existing backend in device enumeration mode
433        # and generate their PyKota accounting counterpart
434        (directory, myname) = os.path.split(sys.argv[0])
435        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)] :
436            answer = os.popen(backend, "r")
437            try :
438                devices = [line.strip() for line in answer.readlines()]
439            except :   
440                devices = []
441            status = answer.close()
442            if status is None :
443                for d in devices :
444                    # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
445                    # so we have to decompose it carefully
446                    fdevice = cStringIO.StringIO("%s" % d)
447                    tokenizer = shlex.shlex(fdevice)
448                    tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#"
449                    arguments = []
450                    while 1 :
451                        token = tokenizer.get_token()
452                        if token :
453                            arguments.append(token)
454                        else :
455                            break
456                    fdevice.close()
457                    try :
458                        (devicetype, device, name, fullname) = arguments
459                    except ValueError :   
460                        pass    # ignore this 'bizarre' device
461                    else :   
462                        if name.startswith('"') and name.endswith('"') :
463                            name = name[1:-1]
464                        if fullname.startswith('"') and fullname.endswith('"') :
465                            fullname = fullname[1:-1]
466                        print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname)
467        retcode = 0
468    elif len(sys.argv) not in (6, 7) :   
469        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0])
470        retcode = 1
471    else :   
472        try :
473            # Initializes the backend
474            kotabackend = PyKotaBackend()   
475            retcode = main(kotabackend)
476        except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg :
477            sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg)
478            sys.stderr.flush()
479            retcode = 1
480       
481        try :
482            kotabackend.storage.close()
483        except (TypeError, NameError, AttributeError) :   
484            pass
485       
486    sys.exit(retcode)   
Note: See TracBrowser for help on using the browser.