root / pykota / trunk / bin / cupspykota @ 1465

Revision 1465, 21.9 kB (checked in by jalet, 20 years ago)

Should be more robust if we receive a SIGTERM during an I/O operation

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