root / pykota / trunk / bin / cupspykota @ 1497

Revision 1497, 23.3 kB (checked in by jalet, 20 years ago)

Now precomputes the job's size only if current printer's enforcement
is "STRICT"

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