#!/usr/bin/python # Adapted by bdolicki for openacs.org # Changes to http://dev.zope.org/CVS/postcommit_actions : # 001 # The original file forced python2.1 because os.popen2 didn't work for them. # on Python 1.5. It does work for us though so we didn't install python2.1 # 002 # Set some variables """Apply checkin actions dictated by traffic_table.py, as checkins occur. Special provisions to fork into the background, disconnected from tty, so the user and (more critically) CVS locks aren't held just pending completion of notification and other potentially meandering actions. Options: --verbose - operate in verbose mode, with output to OUTPUT_LOG in the detached copy, and to stdout before the detach (and forever if --wait specified) --dryrun - do not actually do any of the activities --wait - do not fork into the background (and leave output in stdout/stderr) This is for internal use by the script when forking. --msgfile MSGFILENM - internal - get log message from MSGFILENM (and delete) We expect to be invoked from loginfo with %{CVSROOT} %{sVv}: CVS expands the loginfo %{sVv} into a single command-line token (bizarrely enough) containing multiple, space-delimited parts: - The first part is the path of repository directory - Then comes a comma-concatenated string for each file being checked in, consisting of: - The file name, - The old version number, then - the new one, like so: filename,1.52,1.53 \(See the CVS loginfo docs for more format-string details.) The actions are configured in traffic_table.py. The only remaining relevant one is: - email checkin messages to designated recipients \(Versions of the script that supported automatic mirroring was removed beginning at version 1.123.)""" import sys, os, tempfile, time, pwd SCRIPT_DIR = os.path.abspath(os.path.split(sys.argv[0])[0]) os.chdir(SCRIPT_DIR) # Valid dir, having other local scripts. import string import getopt import smtplib import socket, whrandom, getpass CVSROOT = os.path.split(SCRIPT_DIR)[0] CVSROOT_ABS = os.path.abspath('..') # 002 #USE_LDAP = 1 USE_LDAP = 0 LDAP_PORT = 389 LDAP_HOST = "your.ldap.host" # 002 TODO is it OK to use this address? #OFFICIAL_SENDER = "cvs-admin@your.domain" OFFICIAL_SENDER = "openacs-cvs-list-admin@willfork.com" # Ceiling on number of lines per file report (diff or "added"), at which point # we go to excerpts from the beginning and end: FILE_LINES_LIMIT = 1500 # Number of lines in beginning and end excerpts when FILE_LINES_LIMIT is hit: FILE_EXCERPTS_LINES = 200 CUSTOM_TRAFFIC_TABLE = "%s/custom_traffic_table.py" % CVSROOT import re import traffic_table import adjustlinks # Hook up with an outboard file. if os.path.exists(CUSTOM_TRAFFIC_TABLE): execfile(CUSTOM_TRAFFIC_TABLE) # OUTPUT_LOG must be writable by anyone whose checkins will invoke this # script in order for their logging to happen. OUTPUT_LOG = "%s/cvs-postcommit_actions.log" % CVSROOT # Set CVSMASTER to the email address of someone managing your CVS mirroring. # Notices about any caught malfunctions will be sent to this address. # 002 TODO is it OK to use this address? #CVSMASTER = "cvs-admin@zope.com" CVSMASTER = "openacs-cvs-list-admin@willfork.com" # You have to plug in the recipients email address via '%' string formatting. MAIL_CMD = "/usr/lib/sendmail -t -f %s" WAIT = 0 # Fork unless --wait is requested. DRYRUN = 0 # '--dryrun' option VERBOSE = 0 # '--verbose' option default value SCRIPT = "postcommit_actions" # Will b set to argv[0], when obtained. sVv_re = re.compile("(.*),([^,]+),([^,]+)") def main(args): """Grok the args and the traffic_table and process accordingly.""" global SCRIPT, DRYRUN, VERBOSE, WAIT orig_args = args[:] SCRIPT = args[0] try: opts, args = getopt.getopt(args[1:], "", ["verbose", "dryrun", "wait", "msgfile=", ]) except getopt.error, err: complain("%s\n", err) raise SystemExit, 1 msgfilenm = "" for opt, val in opts: if opt == "--verbose": VERBOSE = 1 complain("%s: VERBOSE\n", SCRIPT) elif opt == "--dryrun": DRYRUN = 1 complain("%s: DRYRUN\n", SCRIPT) elif opt == "--wait": WAIT = 1 complain("%s: WAIT/Foreground\n", SCRIPT) elif opt == "--msgfile": # This is internal, for the script to pass itself a file. msgfilenm = val else: complain("Unrecognized option '%s'\n", opt) raise SystemExit, 1 if VERBOSE: complain("Initial args: %s '%s'\n", string.join(orig_args[:-1], " "), orig_args[-1]) complain("Working dir: %s\n" % safe_getcwd('/tmp')) if len(args) != 1: usage(); raise SystemExit, 1 doing_directory = 0 no_files = 1 # The %{sVv} args are passed in as a single token - have to split them. split_args = string.split(args[0]) repo = split_args[0] if len(split_args) == 0: subjs = ["", "", ""] elif len(split_args) == 1: subjs = split_args + ["", ""] elif split_args[1] == "-": subjs = split_args doing_directory = 1 else: no_files = 0 subjs = map(grok_file, split_args[1:]) if VERBOSE: complain("CWD: %s, Repo: %s, ", safe_getcwd('/tmp'), repo) complain("Subjects: %s\n", subjs) if not WAIT: detach(orig_args) entries = find_entries(repo) for entry in entries: wasVerbose = VERBOSE VERBOSE = entry.get('verbose', VERBOSE) selector_path = entry['path'] addrs = entry.get('addrs') specials = entry.get('specials') if addrs: do_mail(repo, addrs, subjs, msgfilenm=msgfilenm, doing_directory=doing_directory, no_files=no_files) if specials: subj_names = map(lambda x: x[0], subjs) for trigger, action in specials: if trigger in subj_names: do_special(trigger, action, addrs) VERBOSE = wasVerbose if VERBOSE: complain("** Done **\n\n") if failures: handle_failures(orig_args) if msgfilenm: os.unlink(msgfilenm) def detach(args): """Fork us into the background, with stdout and stderr to OUTPUT_LOG. We have to disconnect std io (in, out, and err) and run the program in the background - which we do using some shell wizardry.""" doctored_args = [args[0]] doctored_args.append("--wait") tempfile.mktemp() # Throw one away, to get initial template. tempfile.tempdir = "/tmp" ## template = (tempfile.template or ## "@%s.%d" % (os.getpid(), whrandom.randint(1000000, 9999999))) ## tempfile.template = "cvs-log" + template msgfile = open(tempfile.mktemp(suffix='.cvslog'), 'w') msgfile.write(sys.stdin.read()) msgfile.close() msgfilenm = msgfile.name doctored_args.append("--msgfile") doctored_args.append(msgfilenm) for i in args[1:]: doctored_args.append('"%s"' % i) cmd = (("( exec 1>>%s 2>&1; " + ("export CVSROOT=%s; " % CVSROOT) + string.join(doctored_args, " ") + " &)") % OUTPUT_LOG) if VERBOSE: complain("Re-executing detached in %s, cmd:\n\t%s\n", safe_getcwd('/tmp'), cmd) os.system(cmd) loosen_file(OUTPUT_LOG) os._exit(0) def find_entries(repo=None): """Return dictionary of traffic_table entries qualified by repo regexp. Iff no entries match, we return the catchall entry - the (last) one with path == None.""" entries = [] catchall = None linkmgr = adjustlinks.LinkManager() gotaddrs = [] containers = linkmgr.all_containers(repo) for it in traffic_table.get_table(): if it['path'] == None: # Retain the catchall entry in case no regular ones qualify. catchall = it else: # Obtain qualifying candidates: for candidate in containers: if (re.match(it['path'], candidate) and it.get('addrs') not in gotaddrs): entries.append(it) gotaddrs.append(it.get('addrs')) if entries: if VERBOSE > 1: complain("find_entries: repo: %s, containers: %s\n entries: %s\n", repo, containers, entries) elif catchall: entries.append(catchall) if VERBOSE > 1: complain("No matches, so using catchall:\n %s\n", entries) elif VERBOSE > 1: complain("No matches, no catchall - no actions\n") for e in entries: if e.has_key('addrs') and (type(e['addrs']) == type("")): # Be lenient - listify string args. e['addrs'] = [e['addrs']] return entries def do_mail(repo, addrs, subjs, msgfilenm, doing_directory=0, no_files=0): """Send notice about checkin to addresses dictated by traffic table. We include a diff.""" if VERBOSE: complain("Notice to %s\n for %s / %s\n", addrs, repo, subjs) # The message contents are on stdin, just _yearning_ to be sent...-) subject = "CVS: %s " % repo diff_msg = '' if doing_directory or no_files: subject = subject + string.join(subjs, " ") if subjs[2] == 'New': new_msg = ('=== Added directory %s ===\n' % repo) diff_msg = diff_msg + new_msg + '\n' else: subject = subject + "-" for fn, old, new in subjs: subject = subject + " %s:%s" % (fn, new) if new == 'NONE': new_msg = ('=== Removed File %s/%s ===\n' % (repo, fn)) else: try: new_msg = "\n" + create_diff(repo, fn, old, new) except IOError: if DRYRUN: text = "[Contrived diff]" else: raise diff_msg = diff_msg + new_msg + '\n' try: # Prepend the Subject and From lines, and append the diff: mf = open(msgfilenm, 'r') text = mf.read() + diff_msg mf.close() except IOError: if DRYRUN and WAIT: text = "[Contrived content]\n" + diff_msg else: raise send_mail(addrs, text, subject) def create_diff(repo, file, old, new): """ Create a diff comparing old and new versions """ if old == 'NONE': # A new file was added # We have to change to a neutral dir, or cvs will complain about # doing checkouts into the repository - even though the checkout # is to stdout, sigh. origdir = os.getcwd() try: os.chdir('/tmp') revclause = '' if new and new != 'NONE': revclause = '-r ' + new # "Checkout" to stdout, so we can collect the lines to return. co_stdout_cmd = 'cvs -fn co -p %s %s/%s' % (revclause, repo, file) handle = os.popen(co_stdout_cmd) lines = handle.readlines() handle.close() header = ("=== Added File %s/%s ===" % (repo, file)) finally: os.chdir(origdir) else: # A "normal" update happened diff_cmd = ('cvs -d %s -f rdiff -r %s -r %s -kk -u %s/%s' % (CVSROOT_ABS, old, new, repo, file)) file_handle = os.popen(diff_cmd) lines = file_handle.readlines()[2:] file_handle.close() header = ("=== %s/%s %s => %s ===" % (str(repo), str(file), old, new)) # Provide for initial checkins on branch - in which case, the ,v files # exist only in the Attic. if (old == 'NONE') and (len(new.split('.')) > 2): template = "%s/%s/Attic/%s,v" else: template = "%s/%s/%s,v" commav = template % (CVSROOT_ABS, repo, file) if os.path.exists(commav): isbinary = expands_as_binary(commav) else: complain("Couldn't check binary-ness of missing comma-v" " for %s%s v %s, should be at:\n %s" % (((old == 'NONE') and "new ") or '', file, new, commav)) # Let the diff go ahead: isbinary = None if isbinary: return "%s\n " % header else: total = len(lines) if total >= FILE_LINES_LIMIT: omitted = total - (2 * FILE_EXCERPTS_LINES) # Limit exceeded, show only exercpts from beginning and end. lines = (lines[:FILE_EXCERPTS_LINES] + ['\n', ('[-=- -=- -=- %s lines omitted -=- -=- -=-]\n' % (total - (2 * FILE_EXCERPTS_LINES))), '\n'] + lines[-FILE_EXCERPTS_LINES:]) header = header + " (%s/%s lines abridged)" % (omitted, total) lines.insert(0, header + "\n") return string.join(lines, '') def do_special(trigger, action, addrs): """Do special action - a script, to be invoked from the CVSROOT dir. Run it with the version info and send the result to indicated addrs.""" action_cmd = "%s/CVSROOT/%s %s" % (CVSROOT, action, trigger) file_handle = os.popen(action_cmd) output = file_handle.read() result = file_handle.close() if result: note_failure("*** Special command failed with error %s:\n" "** %s **\n%s\n", result, action_cmd, output) else: subject = "CVS: %s run for %s changes" % (action, trigger) send_mail(addrs, output, subject) complain("%s\n%s\n", subject, output) def send_mail(addrs, text, subject): user = getuser() fullname = email = '' if user: fullname, email = get_user_ldap_info(user) if '.' in fullname or ',' in fullname: fullname = '"' + fullname + '"' else: user = "*unknown*" if not fullname: fullname = "The Unidentified User" if not email: email = OFFICIAL_SENDER cmd_info = {'verbose1': (VERBOSE and "set -x; ") or "", 'dryrun': (DRYRUN and "echo Would do: ") or "", 'mailcmd': MAIL_CMD % email, 'verbose2': (VERBOSE and "-v") or ""} cmd = ("%(verbose1)s%(dryrun)s%(mailcmd)s %(verbose2)s" % cmd_info) if VERBOSE: complain("%sDoing mail cmd for user %s:\n\t%s\n", ((DRYRUN and "NOT ") or ""), user, cmd) envelope_info = {'subject': subject, 'to': string.join(addrs, ", "), 'from': "%s <%s>" % (fullname, email), 'sender': OFFICIAL_SENDER, 'user': user} header = ("Subject: %(subject)s\n" "To: %(to)s\n" "From: %(from)s\n" "Sender: %(sender)s\n" % envelope_info) notice = header + '\n' + text if not DRYRUN: cmd_in, cmd_out = os.popen2(cmd, 'rw') cmd_in.write(notice) cmd_in.close() output = cmd_out.read() result = cmd_out.close() else: result = None if VERBOSE: complain(string.join(map(lambda x: '= ' + x, string.split(header, '\n')), '\n')) if result: note_failure("*** Mail cmd yielded unexpected result %s:\n%s\n", result, output) def loosen_file(fname): """Relax permissions on (newly created) file so others can use it too.""" try: os.chmod(fname, 0777) except os.error, err: pass def grok_file(s): """Separate "file,old-version,new-version".""" m = sVv_re.match(s) if not m: raise ValueError, "'%s' not in file,old-vers,new-vers format" % s return m.groups() failures = 0 def note_failure(msg, *args): """Register a failure for handle_failures to announce at the end.""" global failures failures = 1 apply(complain, (msg,) + args) def getuser(): """Try to get the user's login name.""" try: return getpass.getuser() except: return None def get_user_ldap_info(user): """Obtain some aproximation to user's (fullname, email). We prefer to get the info out of ldap. Failing that, we use whatever we can get from the password file. Failing that, we return an empty fullname and the value of OFFICIAL_SENDER. Failling all, we return ('', '').""" if not user: return ('', '') # Fallback values: email = OFFICIAL_SENDER try: fullname = pwd.getpwnam(user)[4] except KeyError: fullname = '' if USE_LDAP: try: import ldap; ldap_mod = ldap except ImportError: try: import _ldap; ldap_mod = _ldap except ImportError: print "Failed to get any LDAP module, punting on ldap." return (fullname, email) try: c = ldap_mod.open(LDAP_HOST, LDAP_PORT) c.simple_bind_s('', '') # ('sn' for "surname") record = c.search_s('ou=people,o=DC,c=US', ldap_mod.SCOPE_SUBTREE, 'loginName=%s' % user, ['mail', 'sn', 'givenName']) if record: d = record[0][1] email = d.get('mail', [email])[0] first = d.get('givenName', [''])[0] last = d.get('sn', [''])[0] if first or last: if first: first = first + " " fullname = "%s%s" % (first, last) except ldap_mod.LDAPError: pass return (fullname, email) def handle_failures(argstring): """On serious failures, send the complaints log to CVSMASTER.""" if os.environ.has_key('HOSTNAME'): host = os.environ['HOSTNAME'] else: host = socket.gethostbyaddr(socket.gethostname())[0] if not host: host = "nohost.zope.com" user = getuser() or "unidentified" if os.path.isfile(OUTPUT_LOG): log_file_expr = ("\n\tSee log file for application errors:\n\t\t%s" % OUTPUT_LOG) else: log_file_expr = "" complain("Sending complaints log to CVSMASTER %s\n", CVSMASTER) complain("Time stamp: %s\n", time.ctime(time.time())) complain("Fatal failures in %s:%s" "\n\tCheckin by: %s@%s\n\tInvocation: %s\n\n", SCRIPT, log_file_expr, user, host, argstring) cmd = ('%s %s -s "CVS errors in %s for %s" %s' % (MAIL_CMD, ((VERBOSE and "-v") or ""), SCRIPT, user, CVSMASTER)) f = os.popen(cmd, 'w') f.write("Serious errors encountered during CVS postcommit actions,\n") f.write("the log is below with orienting details at the bottom.\n\n") f.write(string.join(complaints, "")) return f.close() def usage(): complain("Usage: %s [options] ", SCRIPT) complain('"repopath file,oldv,newv [f2,o2,n2 ...]"\n') complain("(Note that repo-path and files must be" " quoted so they are a single token.)\n") def get_command(cmd, path): """Get a valid exe for cmd on path.""" for d in path: maybe = os.path.join(d, cmd) if os.path.isfile(maybe): return maybe note_failure("No usable %s executable found.\n", `cmd`) EXPANDS_RE = re.compile("expand.*@b@") def expands_as_binary(repository_path): """Return true if the repository file is marked for binary keyword expansion.""" f = open(repository_path, 'r') while 1: l = f.readline().strip() if not l: # Break out on first blank line or eof: return 0 if EXPANDS_RE.match(l): return 1 complaints = [] def complain(msg, *args): global complaints import time t = time.strftime("%Y-%m-%d %T - ", time.localtime(time.time())) complaints.append(t + (msg % args)) sys.stderr.write(msg % args) sys.stderr.flush() def safe_getcwd(fallback): try: return os.getcwd() except: os.chdir(fallback) try: return os.getcwd() except: return "" if __name__ == "__main__": main(sys.argv)