root / pykota / trunk / bin / cupspykota @ 1495

Revision 1495, 22.9 kB (checked in by jalet, 20 years ago)

New 'enforcement' directive added
Polling loop improvements

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