2
"""Helper to mass-edit jobs in jenkins.
6
###############################################################################
7
# Copyright (c) 2011 Linaro
8
# All rights reserved. This program and the accompanying materials
9
# are made available under the terms of the Eclipse Public License v1.0
10
# which accompanies this distribution, and is available at
11
# http://www.eclipse.org/legal/epl-v10.html
12
###############################################################################
15
from contextlib import nested
21
from tempfile import NamedTemporaryFile
25
from xml.dom import minidom
27
from lxml.etree import fromstring, tostring
30
optparser = optparse.OptionParser(usage="%prog <mangle script>")
31
optparser.add_option("--url", default="http://localhost:8080/jenkins/",
32
help="Jenkins base url, default: %default")
33
optparser.add_option("--user",
34
help="Jenkins username")
35
optparser.add_option("--passwd-file", metavar="FILE",
36
help="File holding Jenkins password")
37
optparser.add_option("--really", action="store_true",
38
help="Actually perform changes")
39
optparser.add_option("--filter-jobname",
40
help="Process only jobs matching regex pattern")
41
optparser.add_option("--limit", type="int", default=-1,
42
help="Change at most LIMIT jobs")
43
optparser.add_option("--file",
44
help="Process a file instead of all jobs on a remote server")
46
options, args = optparser.parse_args(sys.argv[1:])
48
optparser.error("Wrong number of arguments")
51
execfile(args[0], d, d)
55
if options.passwd_file:
56
password = open(options.passwd_file).read().strip()
57
elif not options.file:
58
password = getpass.getpass("Password/API Token:")
60
if options.url[-1] != '/':
64
'Authorization': 'Basic %s' % (
65
base64.encodestring('%s:%s' % (options.user, password))[:-1],),
68
def _authJenkins(jenkins_path, data=None, extra_headers=None):
69
"""Make an authenticated request to jenkins.
71
@param jenkins_path: The path on the Jenkins instance to make the request
73
@param data: Data to include in the request (if this is not None the
74
request will be a POST).
75
@param extra_headers: A dictionary of extra headers that will passed in
76
addition to Authorization.
77
@raises urllib2.HTTPError: If the response is not a HTTP 200.
78
@returns: the body of the response.
80
headers = auth_headers.copy()
82
headers.update(extra_headers)
83
req = urllib2.Request(
84
options.url + jenkins_path, data, headers)
85
resp = urllib2.urlopen(req)
88
def getJobConfig(job_name):
89
return _authJenkins('job/' + job_name + '/config.xml')
91
def postConfig(url, configXml, extra_headers=None):
92
headers = {'Content-Type': 'text/xml', }
93
if extra_headers is not None:
94
headers.update(extra_headers)
95
_authJenkins(url, configXml, headers)
98
# Render XML to exact dialect used by Jenkins
99
# This involves some dirty magic
100
text = tostring(tree, xml_declaration=True, encoding='UTF-8')
101
# Roundtrip via minidom, this takes care of encoding " as entities
102
tree2 = minidom.parseString(text)
103
text = tree2.toxml('UTF-8')
106
text = re.sub(r"<([-A-Za-z.]+)/>", "<\\1></\\1>", text)
108
# Some CR noise should be entities
109
text = text.replace("\r", "
")
111
# Finally, munge xml decl
112
line1, rest = text.split("><", 1)
113
line1 = line1.replace('"', "'")
114
r = line1 + ">\n<" + rest
118
def show_diff(old, new):
119
with nested(NamedTemporaryFile(), NamedTemporaryFile()) as (a, b):
123
os.system('diff -u %s %s' % (a.name, b.name))
126
def indent_tree(elem, level=0):
127
"Indent XML tree for pretty-printing"
130
if not elem.text or not elem.text.strip():
132
if not elem.tail or not elem.tail.strip():
135
indent_tree(elem, level+1)
136
if not elem.tail or not elem.tail.strip():
139
if level and (not elem.tail or not elem.tail.strip()):
142
def normalize2text(tree):
143
"""Return normalized text representation of XML tree, suitable for
144
diffing with normal diff tool."""
145
normalized = copy.deepcopy(tree)
146
indent_tree(normalized)
147
return tostring(normalized)
149
def match_job_name(job_name):
150
"Check if job name matches filters which may be specified on command line."
151
if not options.filter_jobname:
154
r = options.filter_jobname
158
return bool(re.search(r, job_name)) ^ neg
160
def get_csrf_token():
162
crumb_data = _authJenkins('crumbIssuer/api/xml')
163
except urllib2.HTTPError:
164
# Ignore errors for android-build which provides no crumb.
166
tree = minidom.parseString(crumb_data)
167
crumb_tag = tree.getElementsByTagName('crumb')[0]
168
field_tag = tree.getElementsByTagName('crumbRequestField')[0]
169
crumb = str(crumb_tag.firstChild.wholeText)
170
field = str(field_tag.firstChild.wholeText)
171
return (field, crumb)
173
def process_remote_jenkins():
174
jobs = json.load(urllib2.urlopen(options.url + 'api/json?tree=jobs[name]'))
175
names = [job['name'] for job in jobs['jobs']]
176
names = [name for name in names if name == 'blank' or '_' in name]
177
limit = options.limit
179
csrf_token = get_csrf_token()
180
if csrf_token is None:
183
extra_headers = { csrf_token[0]: csrf_token[1], }
186
if not match_job_name(name):
191
print "Processing:" + name
193
org_text = getJobConfig(name)
194
tree = fromstring(org_text)
195
org_normalized = normalize2text(tree)
197
if mangler(tree) == False:
200
if not options.really:
201
new_normalized = normalize2text(tree)
202
show_diff(org_normalized, new_normalized)
204
new_text = render_xml(tree)
205
if type(new_text) == type(u""):
206
new_text = new_text.encode("utf8")
207
postConfig(str('job/' + name + '/config.xml'), new_text,
213
text = open(options.file).read()
214
tree = fromstring(text)
216
org_normalized = normalize2text(tree)
218
new_normalized = normalize2text(tree)
219
show_diff(org_normalized, new_normalized)
221
process_remote_jenkins()
223
if __name__ == "__main__":