root / pykota / trunk / bin / cupspykota @ 1492

Revision 1492, 22.4 kB (checked in by jalet, 20 years ago)

Preliminary work on pre-accounting

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