root / pykota / trunk / bin / cupspykota @ 1493

Revision 1493, 22.8 kB (checked in by jalet, 20 years ago)

More robust (?) code

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