root / pykota / trunk / bin / cupspykota @ 1447

Revision 1433, 20.7 kB (checked in by jalet, 21 years ago)

Began work on correct handling of child processes when jobs are cancelled by
the user. Especially important when an external requester is running for a
long time.

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