#!/usr/bin/python """Helper to mass-edit jobs in jenkins. """ ############################################################################### # Copyright (c) 2011 Linaro # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 # which accompanies this distribution, and is available at # http://www.eclipse.org/legal/epl-v10.html ############################################################################### import base64 from contextlib import nested import json import os import sys import copy import re from tempfile import NamedTemporaryFile import urllib2 import optparse import getpass from xml.dom import minidom from lxml.etree import fromstring, tostring optparser = optparse.OptionParser(usage="%prog ") optparser.add_option("--url", default="http://localhost:8080/jenkins/", help="Jenkins base url, default: %default") optparser.add_option("--user", help="Jenkins username") optparser.add_option("--passwd-file", metavar="FILE", help="File holding Jenkins password") optparser.add_option("--really", action="store_true", help="Actually perform changes") optparser.add_option("--filter-jobname", help="Process only jobs matching regex pattern") optparser.add_option("--limit", type="int", default=-1, help="Change at most LIMIT jobs") optparser.add_option("--file", help="Process a file instead of all jobs on a remote server") options, args = optparser.parse_args(sys.argv[1:]) if len(args) != 1: optparser.error("Wrong number of arguments") d = {} execfile(args[0], d, d) mangler = d['mangle'] password = None if options.passwd_file: password = open(options.passwd_file).read().strip() elif not options.file: password = getpass.getpass("Password/API Token:") if options.url[-1] != '/': options.url += '/' auth_headers = { 'Authorization': 'Basic %s' % ( base64.encodestring('%s:%s' % (options.user, password))[:-1],), } def _authJenkins(jenkins_path, data=None, extra_headers=None): """Make an authenticated request to jenkins. @param jenkins_path: The path on the Jenkins instance to make the request to. @param data: Data to include in the request (if this is not None the request will be a POST). @param extra_headers: A dictionary of extra headers that will passed in addition to Authorization. @raises urllib2.HTTPError: If the response is not a HTTP 200. @returns: the body of the response. """ headers = auth_headers.copy() if extra_headers: headers.update(extra_headers) req = urllib2.Request( options.url + jenkins_path, data, headers) resp = urllib2.urlopen(req) return resp.read() def getJobConfig(job_name): return _authJenkins('job/' + job_name + '/config.xml') def postConfig(url, configXml, extra_headers=None): headers = {'Content-Type': 'text/xml', } if extra_headers is not None: headers.update(extra_headers) _authJenkins(url, configXml, headers) def render_xml(tree): # Render XML to exact dialect used by Jenkins # This involves some dirty magic text = tostring(tree, xml_declaration=True, encoding='UTF-8') # Roundtrip via minidom, this takes care of encoding " as entities tree2 = minidom.parseString(text) text = tree2.toxml('UTF-8') # expand empty tags text = re.sub(r"<([-A-Za-z.]+)/>", "<\\1>", text) # Some CR noise should be entities text = text.replace("\r", " ") # Finally, munge xml decl line1, rest = text.split("><", 1) line1 = line1.replace('"', "'") r = line1 + ">\n<" + rest return r def show_diff(old, new): with nested(NamedTemporaryFile(), NamedTemporaryFile()) as (a, b): a.write(old) b.write(new) a.flush(); b.flush() os.system('diff -u %s %s' % (a.name, b.name)) print def indent_tree(elem, level=0): "Indent XML tree for pretty-printing" i = "\n" + level*" " if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " if not elem.tail or not elem.tail.strip(): elem.tail = i for elem in elem: indent_tree(elem, level+1) if not elem.tail or not elem.tail.strip(): elem.tail = i else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = i def normalize2text(tree): """Return normalized text representation of XML tree, suitable for diffing with normal diff tool.""" normalized = copy.deepcopy(tree) indent_tree(normalized) return tostring(normalized) def match_job_name(job_name): "Check if job name matches filters which may be specified on command line." if not options.filter_jobname: return True neg = False r = options.filter_jobname if r[0] == "-": neg = True r = r[1:] return bool(re.search(r, job_name)) ^ neg def get_csrf_token(): try: crumb_data = _authJenkins('crumbIssuer/api/xml') except urllib2.HTTPError: # Ignore errors for android-build which provides no crumb. return None tree = minidom.parseString(crumb_data) crumb_tag = tree.getElementsByTagName('crumb')[0] field_tag = tree.getElementsByTagName('crumbRequestField')[0] crumb = str(crumb_tag.firstChild.wholeText) field = str(field_tag.firstChild.wholeText) return (field, crumb) def process_remote_jenkins(): jobs = json.load(urllib2.urlopen(options.url + 'api/json?tree=jobs[name]')) names = [job['name'] for job in jobs['jobs']] names = [name for name in names if name == 'blank' or '_' in name] limit = options.limit csrf_token = get_csrf_token() if csrf_token is None: extra_headers = None else: extra_headers = { csrf_token[0]: csrf_token[1], } for name in names: if not match_job_name(name): continue if limit == 0: break limit -= 1 print "Processing:" + name sys.stdout.flush() org_text = getJobConfig(name) tree = fromstring(org_text) org_normalized = normalize2text(tree) if mangler(tree) == False: continue if not options.really: new_normalized = normalize2text(tree) show_diff(org_normalized, new_normalized) else: new_text = render_xml(tree) if type(new_text) == type(u""): new_text = new_text.encode("utf8") postConfig(str('job/' + name + '/config.xml'), new_text, extra_headers) def main(): if options.file: text = open(options.file).read() tree = fromstring(text) org_normalized = normalize2text(tree) mangler(tree) new_normalized = normalize2text(tree) show_diff(org_normalized, new_normalized) else: process_remote_jenkins() if __name__ == "__main__": main()