root / pykota / trunk / bin / cupspykota @ 1411

Revision 1411, 21.5 kB (checked in by jalet, 20 years ago)

Fix for raw jobs in cupspykota

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