root / pykota / trunk / bin / cupspykota @ 1410

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

Added fsync() calls

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