4
# Licensed to the Apache Software Foundation (ASF) under one or more
5
# contributor license agreements. See the NOTICE file distributed with
6
# this work for additional information regarding copyright ownership.
7
# The ASF licenses this file to You under the Apache License, Version 2.0
8
# (the "License"); you may not use this file except in compliance with
9
# the License. You may obtain a copy of the License at
11
# http://www.apache.org/licenses/LICENSE-2.0
13
# Unless required by applicable law or agreed to in writing, software
14
# distributed under the License is distributed on an "AS IS" BASIS,
15
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
# See the License for the specific language governing permissions and
17
# limitations under the License.
20
# IrkerBridge - Bridge an SvnPubSub stream to Irker.
23
# irkerbridge.py --daemon --pidfile pid --logfile log config
25
# For detailed option help use:
26
# irkerbridge.py --help
28
# It expects a config file that has the following parameters:
30
# Space separated list of URLs to streams.
31
# This option should only be in the DEFAULT section, is ignored in
34
# The hostname/port combination of the irker daemon. If port is
35
# omitted it defaults to 6659. Irker is connected to over UDP.
36
# match=What to use to decide if the commit should be sent to irker.
37
# It consists of the repository UUID followed by a slash and a glob pattern.
38
# The UUID may be replaced by a * to match all UUIDs. The glob pattern will
39
# be matched against all of the dirs_changed. Both the UUID and the glob
40
# pattern must match to send the message to irker.
42
# Space separated list of URLs (any URL that Irker will accept) to
43
# send the resulting message to. At current Irker only supports IRC.
45
# A string to use to format the output. The string is a Python
46
# string Template. The following variables are available:
47
# $committer, $id, $date, $repository, $log, $log_firstline,
48
# $log_firstparagraph, $dirs_changed, $dirs_count, $dirs_count_s,
49
# $subdirs_count, $subdirs_count_s, $dirs_root
50
# Most of them should be self explanatory. $dirs_count is the number of
51
# entries in $dirs_changed, $dirs_count_s is a friendly string version,
52
# $dirs_root is the common root of all the $dirs_changed, $subdirs_count
53
# is the number of subdirs under the $dirs_root that changed,
54
# $subdirs_root_s is a friendly string version. $log_firstparagraph cuts
55
# the log message at the first blank line and replaces newlines with spaces.
57
# Within the config file you have sections. Any configuration option
58
# missing from a given section is found in the [DEFAULT] section.
60
# Section names are arbitrary names that mean nothing to the bridge. Each
61
# section other than the [DEFAULT] section consists of a configuration that
62
# may match and send a message to irker to deliver. All matching sections
63
# will generate a message.
65
# Interpolation of values within the config file is allowed by including
66
# %(name)s within a value. For example I can reference the UUID of a repo
67
# repeatedly by doing:
69
# ASF_REPO=13f79535-47bb-0310-9956-ffa450edef68
74
# You can HUP the process to reload the config file without restarting the
75
# process. However, you cannot change the streams it is listening to without
76
# restarting the process.
78
# TODO: Logging in a better way.
80
# Messages longer than this will be truncated and ... added to the end such
81
# that the resulting message is no longer than this:
96
from string import Template
98
# Packages that come with svnpubsub
99
import svnpubsub.client
102
class Daemon(daemonize.Daemon):
103
def __init__(self, logfile, pidfile, bdec):
104
daemonize.Daemon.__init__(self, logfile, pidfile)
109
# There is no setup which the parent needs to wait for.
113
print 'irkerbridge started, pid=%d' % (os.getpid())
115
mc = svnpubsub.client.MultiClient(self.bdec.urls,
121
class BigDoEverythingClass(object):
122
def __init__(self, config, options):
124
self.options = options
125
self.urls = config.get_value('streams').split()
127
def locate_matching_configs(self, commit):
129
for section in self.config.sections():
130
match = self.config.get(section, "match").split('/', 1)
132
# No slash so assume all paths
134
match_uuid, match_path = match
135
if commit.repository == match_uuid or match_uuid == "*":
136
for path in commit.changed:
137
if fnmatch.fnmatch(path, match_path):
138
result.append(section)
142
def _generate_dirs_changed(self, commit):
143
if hasattr(commit, 'dirs_changed') or not hasattr(commit, 'changed'):
147
for p in commit.changed:
148
if p[-1] == '/' and commit.changed[p]['flags'][1] == 'U':
149
# directory with property changes add the directory itself.
152
# everything else add the parent of the path
153
# directories have a trailing slash so if it's present remove
154
# it before finding the parent. The result will be a directory
155
# so it needs a trailing slash
156
dirs_changed.add(posixpath.dirname(p.rstrip('/')) + '/')
158
commit.dirs_changed = dirs_changed
161
def fill_in_extra_args(self, commit):
162
# Set any empty members to the string "<null>"
168
self._generate_dirs_changed(commit)
169
# Add entries to the commit object that are useful for
171
commit.log_firstline = commit.log.split("\n",1)[0]
172
commit.log_firstparagraph = re.split("\r?\n\r?\n",commit.log,1)[0]
173
commit.log_firstparagraph = re.sub("\r?\n"," ",commit.log_firstparagraph)
174
if commit.dirs_changed:
175
commit.dirs_root = posixpath.commonprefix(commit.dirs_changed)
176
if commit.dirs_root == '':
177
commit.dirs_root = '/'
178
commit.dirs_count = len(commit.dirs_changed)
179
if commit.dirs_count > 1:
180
commit.dirs_count_s = " (%d dirs)" %(commit.dirs_count)
182
commit.dirs_count_s = ""
184
commit.subdirs_count = commit.dirs_count
185
if commit.dirs_root in commit.dirs_changed:
186
commit.subdirs_count -= 1
187
if commit.subdirs_count >= 1:
188
commit.subdirs_count_s = " + %d subdirs" % (commit.subdirs_count)
190
commit.subdirs_count_s = ""
192
def _send(self, irker, msg):
193
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
194
irker_list = irker.split(':')
195
if len(irker_list) < 2:
196
irker_list.append(6659)
197
json_msg = json.dumps(msg)
198
sock.sendto(json_msg, (irker_list[0],int(irker_list[1])))
199
if self.options.verbose:
200
print "SENT: %s to %s" % (json_msg, irker)
203
# Like self.commit(), but ignores self.config.get(section, "template").
204
for section in self.config.sections():
205
irker = self.config.get(section, "irker")
206
to_list = self.config.get(section, "to").split()
207
if not irker or not to_list:
210
msg = {'to': to, 'privmsg': ''}
211
self._send(irker, msg)
213
def commit(self, url, commit):
214
if self.options.verbose:
215
print "RECV: from %s" % url
216
print json.dumps(vars(commit), indent=2)
219
config_sections = self.locate_matching_configs(commit)
220
if len(config_sections) > 0:
221
self.fill_in_extra_args(commit)
222
for section in config_sections:
223
irker = self.config.get(section, "irker")
224
to_list = self.config.get(section, "to").split()
225
template = self.config.get(section, "template")
226
if not irker or not to_list or not template:
228
privmsg = Template(template).safe_substitute(vars(commit))
229
if len(privmsg) > MAX_PRIVMSG:
230
privmsg = privmsg[:MAX_PRIVMSG-3] + '...'
232
msg = {'to': to, 'privmsg': privmsg}
233
self._send(irker, msg)
236
print "Unexpected error:"
237
traceback.print_exc()
241
def event(self, url, event_name, event_arg):
242
if self.options.verbose or event_name != "ping":
243
print 'EVENT: %s from %s' % (event_name, url)
248
class ReloadableConfig(ConfigParser.SafeConfigParser):
249
def __init__(self, fname):
250
ConfigParser.SafeConfigParser.__init__(self)
255
signal.signal(signal.SIGHUP, self.hangup)
257
def hangup(self, signalnum, frame):
261
print "RELOAD: config file: %s" % self.fname
264
# Delete everything. Just re-reading would overlay, and would not
265
# remove sections/options. Note that [DEFAULT] will not be removed.
266
for section in self.sections():
267
self.remove_section(section)
269
# Get rid of [DEFAULT]
270
self.remove_section(ConfigParser.DEFAULTSECT)
272
# Now re-read the configuration file.
273
self.read(self.fname)
275
def get_value(self, which):
276
return self.get(ConfigParser.DEFAULTSECT, which)
280
parser = optparse.OptionParser(
281
description='An SvnPubSub client that bridges the data to irker.',
282
usage='Usage: %prog [options] CONFIG_FILE',
284
parser.add_option('--logfile',
285
help='filename for logging')
286
parser.add_option('--verbose', action='store_true',
287
help="enable verbose logging")
288
parser.add_option('--pidfile',
289
help="the process' PID will be written to this file")
290
parser.add_option('--daemon', action='store_true',
291
help='run as a background daemon')
293
options, extra = parser.parse_args(args)
296
parser.error('CONFIG_FILE is requried')
297
config_file = os.path.abspath(extra[0])
299
logfile, pidfile = None, None
302
logfile = os.path.abspath(options.logfile)
304
parser.error('LOGFILE is required when running as a daemon')
307
pidfile = os.path.abspath(options.pidfile)
309
parser.error('PIDFILE is required when running as a daemon')
312
config = ReloadableConfig(config_file)
313
bdec = BigDoEverythingClass(config, options)
315
d = Daemon(logfile, pidfile, bdec)
321
if __name__ == "__main__":