Remove YamlParser from jenkins_jobs.builder
The goal of this patch is simply to move some classes out of jenkins_jobs.builder into more appropriately-named modules. This started with simply moving YamlParser into jenkins_jobs.parser but led to other moves in order to avoid cyclic imports since YamlParser uses other classes previously defined in jenkins_jobs.builder. That said, this patch doesn't intend to address all of the clutter in jenkins_jobs.builder, mostly just what is necessary to get started working on YamlParser independent of other classes in that module. Change-Id: Ie88bf683e495033eb0b670fe29c256a70282735f
This commit is contained in:
parent
c29066210c
commit
4a8b93b8c2
@ -83,5 +83,5 @@ All modules and their associated components are registered in the
|
|||||||
module registry. It can be accessed either from modules via the registry
|
module registry. It can be accessed either from modules via the registry
|
||||||
field, or via the parser parameter of components.
|
field, or via the parser parameter of components.
|
||||||
|
|
||||||
.. autoclass:: jenkins_jobs.builder.ModuleRegistry
|
.. autoclass:: jenkins_jobs.registry.ModuleRegistry
|
||||||
:members:
|
:members:
|
||||||
|
@ -26,40 +26,13 @@ import xml
|
|||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
import jenkins
|
import jenkins
|
||||||
import re
|
import re
|
||||||
import pkg_resources
|
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
import logging
|
import logging
|
||||||
import copy
|
|
||||||
import itertools
|
from jenkins_jobs.constants import MAGIC_MANAGE_STRING
|
||||||
import fnmatch
|
from jenkins_jobs.parser import YamlParser
|
||||||
from string import Formatter
|
|
||||||
from jenkins_jobs.errors import JenkinsJobsException
|
|
||||||
import jenkins_jobs.local_yaml as local_yaml
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MAGIC_MANAGE_STRING = "<!-- Managed by Jenkins Job Builder -->"
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFormatter(Formatter):
|
|
||||||
"""
|
|
||||||
Custom formatter to allow non-existing key references when formatting a
|
|
||||||
string
|
|
||||||
"""
|
|
||||||
def __init__(self, allow_empty=False):
|
|
||||||
super(CustomFormatter, self).__init__()
|
|
||||||
self.allow_empty = allow_empty
|
|
||||||
|
|
||||||
def get_value(self, key, args, kwargs):
|
|
||||||
try:
|
|
||||||
return Formatter.get_value(self, key, args, kwargs)
|
|
||||||
except KeyError:
|
|
||||||
if self.allow_empty:
|
|
||||||
logger.debug(
|
|
||||||
'Found uninitialized key %s, replaced with empty string',
|
|
||||||
key
|
|
||||||
)
|
|
||||||
return ''
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# Python 2.6's minidom toprettyxml produces broken output by adding extraneous
|
# Python 2.6's minidom toprettyxml produces broken output by adding extraneous
|
||||||
@ -99,526 +72,6 @@ if sys.version_info[:3] < (2, 7, 3) or xml.__name__ != 'xml':
|
|||||||
minidom.Element.writexml = writexml
|
minidom.Element.writexml = writexml
|
||||||
|
|
||||||
|
|
||||||
def deep_format(obj, paramdict, allow_empty=False):
|
|
||||||
"""Apply the paramdict via str.format() to all string objects found within
|
|
||||||
the supplied obj. Lists and dicts are traversed recursively."""
|
|
||||||
# YAML serialisation was originally used to achieve this, but that places
|
|
||||||
# limitations on the values in paramdict - the post-format result must
|
|
||||||
# still be valid YAML (so substituting-in a string containing quotes, for
|
|
||||||
# example, is problematic).
|
|
||||||
if hasattr(obj, 'format'):
|
|
||||||
try:
|
|
||||||
result = re.match('^{obj:(?P<key>\w+)}$', obj)
|
|
||||||
if result is not None:
|
|
||||||
ret = paramdict[result.group("key")]
|
|
||||||
else:
|
|
||||||
ret = CustomFormatter(allow_empty).format(obj, **paramdict)
|
|
||||||
except KeyError as exc:
|
|
||||||
missing_key = exc.message
|
|
||||||
desc = "%s parameter missing to format %s\nGiven:\n%s" % (
|
|
||||||
missing_key, obj, pformat(paramdict))
|
|
||||||
raise JenkinsJobsException(desc)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
ret = []
|
|
||||||
for item in obj:
|
|
||||||
ret.append(deep_format(item, paramdict, allow_empty))
|
|
||||||
elif isinstance(obj, dict):
|
|
||||||
ret = {}
|
|
||||||
for item in obj:
|
|
||||||
try:
|
|
||||||
ret[CustomFormatter(allow_empty).format(item, **paramdict)] = \
|
|
||||||
deep_format(obj[item], paramdict, allow_empty)
|
|
||||||
except KeyError as exc:
|
|
||||||
missing_key = exc.message
|
|
||||||
desc = "%s parameter missing to format %s\nGiven:\n%s" % (
|
|
||||||
missing_key, obj, pformat(paramdict))
|
|
||||||
raise JenkinsJobsException(desc)
|
|
||||||
else:
|
|
||||||
ret = obj
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def matches(what, glob_patterns):
|
|
||||||
"""
|
|
||||||
Checks if the given string, ``what``, matches any of the glob patterns in
|
|
||||||
the iterable, ``glob_patterns``
|
|
||||||
|
|
||||||
:arg str what: String that we want to test if it matches a pattern
|
|
||||||
:arg iterable glob_patterns: glob patterns to match (list, tuple, set,
|
|
||||||
etc.)
|
|
||||||
"""
|
|
||||||
return any(fnmatch.fnmatch(what, glob_pattern)
|
|
||||||
for glob_pattern in glob_patterns)
|
|
||||||
|
|
||||||
|
|
||||||
class YamlParser(object):
|
|
||||||
def __init__(self, config=None, plugins_info=None):
|
|
||||||
self.data = {}
|
|
||||||
self.jobs = []
|
|
||||||
self.xml_jobs = []
|
|
||||||
self.config = config
|
|
||||||
self.registry = ModuleRegistry(self.config, plugins_info)
|
|
||||||
self.path = ["."]
|
|
||||||
if self.config:
|
|
||||||
if config.has_section('job_builder') and \
|
|
||||||
config.has_option('job_builder', 'include_path'):
|
|
||||||
self.path = config.get('job_builder',
|
|
||||||
'include_path').split(':')
|
|
||||||
self.keep_desc = self.get_keep_desc()
|
|
||||||
|
|
||||||
def get_keep_desc(self):
|
|
||||||
keep_desc = False
|
|
||||||
if self.config and self.config.has_section('job_builder') and \
|
|
||||||
self.config.has_option('job_builder', 'keep_descriptions'):
|
|
||||||
keep_desc = self.config.getboolean('job_builder',
|
|
||||||
'keep_descriptions')
|
|
||||||
return keep_desc
|
|
||||||
|
|
||||||
def parse_fp(self, fp):
|
|
||||||
data = local_yaml.load(fp, search_path=self.path)
|
|
||||||
if data:
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise JenkinsJobsException(
|
|
||||||
"The topmost collection in file '{fname}' must be a list,"
|
|
||||||
" not a {cls}".format(fname=getattr(fp, 'name', fp),
|
|
||||||
cls=type(data)))
|
|
||||||
for item in data:
|
|
||||||
cls, dfn = next(iter(item.items()))
|
|
||||||
group = self.data.get(cls, {})
|
|
||||||
if len(item.items()) > 1:
|
|
||||||
n = None
|
|
||||||
for k, v in item.items():
|
|
||||||
if k == "name":
|
|
||||||
n = v
|
|
||||||
break
|
|
||||||
# Syntax error
|
|
||||||
raise JenkinsJobsException("Syntax error, for item "
|
|
||||||
"named '{0}'. Missing indent?"
|
|
||||||
.format(n))
|
|
||||||
name = dfn['name']
|
|
||||||
if name in group:
|
|
||||||
self._handle_dups("Duplicate entry found in '{0}: '{1}' "
|
|
||||||
"already defined".format(fp.name, name))
|
|
||||||
group[name] = dfn
|
|
||||||
self.data[cls] = group
|
|
||||||
|
|
||||||
def parse(self, fn):
|
|
||||||
with open(fn) as fp:
|
|
||||||
self.parse_fp(fp)
|
|
||||||
|
|
||||||
def _handle_dups(self, message):
|
|
||||||
|
|
||||||
if not (self.config and self.config.has_section('job_builder') and
|
|
||||||
self.config.getboolean('job_builder', 'allow_duplicates')):
|
|
||||||
logger.error(message)
|
|
||||||
raise JenkinsJobsException(message)
|
|
||||||
else:
|
|
||||||
logger.warn(message)
|
|
||||||
|
|
||||||
def getJob(self, name):
|
|
||||||
job = self.data.get('job', {}).get(name, None)
|
|
||||||
if not job:
|
|
||||||
return job
|
|
||||||
return self.applyDefaults(job)
|
|
||||||
|
|
||||||
def getJobGroup(self, name):
|
|
||||||
return self.data.get('job-group', {}).get(name, None)
|
|
||||||
|
|
||||||
def getJobTemplate(self, name):
|
|
||||||
job = self.data.get('job-template', {}).get(name, None)
|
|
||||||
if not job:
|
|
||||||
return job
|
|
||||||
return self.applyDefaults(job)
|
|
||||||
|
|
||||||
def applyDefaults(self, data, override_dict=None):
|
|
||||||
if override_dict is None:
|
|
||||||
override_dict = {}
|
|
||||||
|
|
||||||
whichdefaults = data.get('defaults', 'global')
|
|
||||||
defaults = copy.deepcopy(self.data.get('defaults',
|
|
||||||
{}).get(whichdefaults, {}))
|
|
||||||
if defaults == {} and whichdefaults != 'global':
|
|
||||||
raise JenkinsJobsException("Unknown defaults set: '{0}'"
|
|
||||||
.format(whichdefaults))
|
|
||||||
|
|
||||||
for key in override_dict.keys():
|
|
||||||
if key in defaults.keys():
|
|
||||||
defaults[key] = override_dict[key]
|
|
||||||
|
|
||||||
newdata = {}
|
|
||||||
newdata.update(defaults)
|
|
||||||
newdata.update(data)
|
|
||||||
return newdata
|
|
||||||
|
|
||||||
def formatDescription(self, job):
|
|
||||||
if self.keep_desc:
|
|
||||||
description = job.get("description", None)
|
|
||||||
else:
|
|
||||||
description = job.get("description", '')
|
|
||||||
if description is not None:
|
|
||||||
job["description"] = description + \
|
|
||||||
self.get_managed_string().lstrip()
|
|
||||||
|
|
||||||
def expandYaml(self, jobs_glob=None):
|
|
||||||
changed = True
|
|
||||||
while changed:
|
|
||||||
changed = False
|
|
||||||
for module in self.registry.modules:
|
|
||||||
if hasattr(module, 'handle_data'):
|
|
||||||
if module.handle_data(self):
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
for job in self.data.get('job', {}).values():
|
|
||||||
if jobs_glob and not matches(job['name'], jobs_glob):
|
|
||||||
logger.debug("Ignoring job {0}".format(job['name']))
|
|
||||||
continue
|
|
||||||
logger.debug("Expanding job '{0}'".format(job['name']))
|
|
||||||
job = self.applyDefaults(job)
|
|
||||||
self.formatDescription(job)
|
|
||||||
self.jobs.append(job)
|
|
||||||
for project in self.data.get('project', {}).values():
|
|
||||||
logger.debug("Expanding project '{0}'".format(project['name']))
|
|
||||||
# use a set to check for duplicate job references in projects
|
|
||||||
seen = set()
|
|
||||||
for jobspec in project.get('jobs', []):
|
|
||||||
if isinstance(jobspec, dict):
|
|
||||||
# Singleton dict containing dict of job-specific params
|
|
||||||
jobname, jobparams = next(iter(jobspec.items()))
|
|
||||||
if not isinstance(jobparams, dict):
|
|
||||||
jobparams = {}
|
|
||||||
else:
|
|
||||||
jobname = jobspec
|
|
||||||
jobparams = {}
|
|
||||||
job = self.getJob(jobname)
|
|
||||||
if job:
|
|
||||||
# Just naming an existing defined job
|
|
||||||
if jobname in seen:
|
|
||||||
self._handle_dups("Duplicate job '{0}' specified "
|
|
||||||
"for project '{1}'".format(
|
|
||||||
jobname, project['name']))
|
|
||||||
seen.add(jobname)
|
|
||||||
continue
|
|
||||||
# see if it's a job group
|
|
||||||
group = self.getJobGroup(jobname)
|
|
||||||
if group:
|
|
||||||
for group_jobspec in group['jobs']:
|
|
||||||
if isinstance(group_jobspec, dict):
|
|
||||||
group_jobname, group_jobparams = \
|
|
||||||
next(iter(group_jobspec.items()))
|
|
||||||
if not isinstance(group_jobparams, dict):
|
|
||||||
group_jobparams = {}
|
|
||||||
else:
|
|
||||||
group_jobname = group_jobspec
|
|
||||||
group_jobparams = {}
|
|
||||||
job = self.getJob(group_jobname)
|
|
||||||
if job:
|
|
||||||
if group_jobname in seen:
|
|
||||||
self._handle_dups(
|
|
||||||
"Duplicate job '{0}' specified for "
|
|
||||||
"project '{1}'".format(group_jobname,
|
|
||||||
project['name']))
|
|
||||||
seen.add(group_jobname)
|
|
||||||
continue
|
|
||||||
template = self.getJobTemplate(group_jobname)
|
|
||||||
# Allow a group to override parameters set by a project
|
|
||||||
d = {}
|
|
||||||
d.update(project)
|
|
||||||
d.update(jobparams)
|
|
||||||
d.update(group)
|
|
||||||
d.update(group_jobparams)
|
|
||||||
# Except name, since the group's name is not useful
|
|
||||||
d['name'] = project['name']
|
|
||||||
if template:
|
|
||||||
self.expandYamlForTemplateJob(d, template,
|
|
||||||
jobs_glob)
|
|
||||||
continue
|
|
||||||
# see if it's a template
|
|
||||||
template = self.getJobTemplate(jobname)
|
|
||||||
if template:
|
|
||||||
d = {}
|
|
||||||
d.update(project)
|
|
||||||
d.update(jobparams)
|
|
||||||
self.expandYamlForTemplateJob(d, template, jobs_glob)
|
|
||||||
else:
|
|
||||||
raise JenkinsJobsException("Failed to find suitable "
|
|
||||||
"template named '{0}'"
|
|
||||||
.format(jobname))
|
|
||||||
# check for duplicate generated jobs
|
|
||||||
seen = set()
|
|
||||||
# walk the list in reverse so that last definition wins
|
|
||||||
for job in self.jobs[::-1]:
|
|
||||||
if job['name'] in seen:
|
|
||||||
self._handle_dups("Duplicate definitions for job '{0}' "
|
|
||||||
"specified".format(job['name']))
|
|
||||||
self.jobs.remove(job)
|
|
||||||
seen.add(job['name'])
|
|
||||||
|
|
||||||
def expandYamlForTemplateJob(self, project, template, jobs_glob=None):
|
|
||||||
dimensions = []
|
|
||||||
template_name = template['name']
|
|
||||||
# reject keys that are not useful during yaml expansion
|
|
||||||
for k in ['jobs']:
|
|
||||||
project.pop(k)
|
|
||||||
for (k, v) in project.items():
|
|
||||||
tmpk = '{{{0}}}'.format(k)
|
|
||||||
if tmpk not in template_name:
|
|
||||||
logger.debug("Variable %s not in name %s, rejecting from job"
|
|
||||||
" matrix expansion.", tmpk, template_name)
|
|
||||||
continue
|
|
||||||
if type(v) == list:
|
|
||||||
dimensions.append(zip([k] * len(v), v))
|
|
||||||
# XXX somewhat hackish to ensure we actually have a single
|
|
||||||
# pass through the loop
|
|
||||||
if len(dimensions) == 0:
|
|
||||||
dimensions = [(("", ""),)]
|
|
||||||
|
|
||||||
for values in itertools.product(*dimensions):
|
|
||||||
params = copy.deepcopy(project)
|
|
||||||
params = self.applyDefaults(params, template)
|
|
||||||
|
|
||||||
expanded_values = {}
|
|
||||||
for (k, v) in values:
|
|
||||||
if isinstance(v, dict):
|
|
||||||
inner_key = next(iter(v))
|
|
||||||
expanded_values[k] = inner_key
|
|
||||||
expanded_values.update(v[inner_key])
|
|
||||||
else:
|
|
||||||
expanded_values[k] = v
|
|
||||||
|
|
||||||
params.update(expanded_values)
|
|
||||||
params = deep_format(params, params)
|
|
||||||
allow_empty_variables = self.config \
|
|
||||||
and self.config.has_section('job_builder') \
|
|
||||||
and self.config.has_option(
|
|
||||||
'job_builder', 'allow_empty_variables') \
|
|
||||||
and self.config.getboolean(
|
|
||||||
'job_builder', 'allow_empty_variables')
|
|
||||||
expanded = deep_format(template, params, allow_empty_variables)
|
|
||||||
|
|
||||||
job_name = expanded.get('name')
|
|
||||||
if jobs_glob and not matches(job_name, jobs_glob):
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.formatDescription(expanded)
|
|
||||||
self.jobs.append(expanded)
|
|
||||||
|
|
||||||
def get_managed_string(self):
|
|
||||||
# The \n\n is not hard coded, because they get stripped if the
|
|
||||||
# project does not otherwise have a description.
|
|
||||||
return "\n\n" + MAGIC_MANAGE_STRING
|
|
||||||
|
|
||||||
def generateXML(self):
|
|
||||||
for job in self.jobs:
|
|
||||||
self.xml_jobs.append(self.getXMLForJob(job))
|
|
||||||
|
|
||||||
def getXMLForJob(self, data):
|
|
||||||
kind = data.get('project-type', 'freestyle')
|
|
||||||
|
|
||||||
for ep in pkg_resources.iter_entry_points(
|
|
||||||
group='jenkins_jobs.projects', name=kind):
|
|
||||||
Mod = ep.load()
|
|
||||||
mod = Mod(self.registry)
|
|
||||||
xml = mod.root_xml(data)
|
|
||||||
self.gen_xml(xml, data)
|
|
||||||
job = XmlJob(xml, data['name'])
|
|
||||||
return job
|
|
||||||
|
|
||||||
def gen_xml(self, xml, data):
|
|
||||||
for module in self.registry.modules:
|
|
||||||
if hasattr(module, 'gen_xml'):
|
|
||||||
module.gen_xml(self, xml, data)
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleRegistry(object):
|
|
||||||
entry_points_cache = {}
|
|
||||||
|
|
||||||
def __init__(self, config, plugins_list=None):
|
|
||||||
self.modules = []
|
|
||||||
self.modules_by_component_type = {}
|
|
||||||
self.handlers = {}
|
|
||||||
self.global_config = config
|
|
||||||
|
|
||||||
if plugins_list is None:
|
|
||||||
self.plugins_dict = {}
|
|
||||||
else:
|
|
||||||
self.plugins_dict = self._get_plugins_info_dict(plugins_list)
|
|
||||||
|
|
||||||
for entrypoint in pkg_resources.iter_entry_points(
|
|
||||||
group='jenkins_jobs.modules'):
|
|
||||||
Mod = entrypoint.load()
|
|
||||||
mod = Mod(self)
|
|
||||||
self.modules.append(mod)
|
|
||||||
self.modules.sort(key=operator.attrgetter('sequence'))
|
|
||||||
if mod.component_type is not None:
|
|
||||||
self.modules_by_component_type[mod.component_type] = mod
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_plugins_info_dict(plugins_list):
|
|
||||||
def mutate_plugin_info(plugin_info):
|
|
||||||
"""
|
|
||||||
We perform mutations on a single member of plugin_info here, then
|
|
||||||
return a dictionary with the longName and shortName of the plugin
|
|
||||||
mapped to its plugin info dictionary.
|
|
||||||
"""
|
|
||||||
version = plugin_info.get('version', '0')
|
|
||||||
plugin_info['version'] = re.sub(r'(.*)-(?:SNAPSHOT|BETA)',
|
|
||||||
r'\g<1>.preview', version)
|
|
||||||
|
|
||||||
aliases = []
|
|
||||||
for key in ['longName', 'shortName']:
|
|
||||||
value = plugin_info.get(key, None)
|
|
||||||
if value is not None:
|
|
||||||
aliases.append(value)
|
|
||||||
|
|
||||||
plugin_info_dict = {}
|
|
||||||
for name in aliases:
|
|
||||||
plugin_info_dict[name] = plugin_info
|
|
||||||
|
|
||||||
return plugin_info_dict
|
|
||||||
|
|
||||||
list_of_dicts = [mutate_plugin_info(v) for v in plugins_list]
|
|
||||||
|
|
||||||
plugins_info_dict = {}
|
|
||||||
for d in list_of_dicts:
|
|
||||||
plugins_info_dict.update(d)
|
|
||||||
|
|
||||||
return plugins_info_dict
|
|
||||||
|
|
||||||
def get_plugin_info(self, plugin_name):
|
|
||||||
""" This method is intended to provide information about plugins within
|
|
||||||
a given module's implementation of Base.gen_xml. The return value is a
|
|
||||||
dictionary with data obtained directly from a running Jenkins instance.
|
|
||||||
This allows module authors to differentiate generated XML output based
|
|
||||||
on information such as specific plugin versions.
|
|
||||||
|
|
||||||
:arg string plugin_name: Either the shortName or longName of a plugin
|
|
||||||
as see in a query that looks like:
|
|
||||||
``http://<jenkins-hostname>/pluginManager/api/json?pretty&depth=2``
|
|
||||||
|
|
||||||
During a 'test' run, it is possible to override JJB's query to a live
|
|
||||||
Jenkins instance by passing it a path to a file containing a YAML list
|
|
||||||
of dictionaries that mimics the plugin properties you want your test
|
|
||||||
output to reflect::
|
|
||||||
|
|
||||||
jenkins-jobs test -p /path/to/plugins-info.yaml
|
|
||||||
|
|
||||||
Below is example YAML that might be included in
|
|
||||||
/path/to/plugins-info.yaml.
|
|
||||||
|
|
||||||
.. literalinclude:: /../../tests/cmd/fixtures/plugins-info.yaml
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.plugins_dict.get(plugin_name, {})
|
|
||||||
|
|
||||||
def registerHandler(self, category, name, method):
|
|
||||||
cat_dict = self.handlers.get(category, {})
|
|
||||||
if not cat_dict:
|
|
||||||
self.handlers[category] = cat_dict
|
|
||||||
cat_dict[name] = method
|
|
||||||
|
|
||||||
def getHandler(self, category, name):
|
|
||||||
return self.handlers[category][name]
|
|
||||||
|
|
||||||
def dispatch(self, component_type,
|
|
||||||
parser, xml_parent,
|
|
||||||
component, template_data={}):
|
|
||||||
"""This is a method that you can call from your implementation of
|
|
||||||
Base.gen_xml or component. It allows modules to define a type
|
|
||||||
of component, and benefit from extensibility via Python
|
|
||||||
entry points and Jenkins Job Builder :ref:`Macros <macro>`.
|
|
||||||
|
|
||||||
:arg string component_type: the name of the component
|
|
||||||
(e.g., `builder`)
|
|
||||||
:arg YAMLParser parser: the global YAML Parser
|
|
||||||
:arg Element xml_parent: the parent XML element
|
|
||||||
:arg dict template_data: values that should be interpolated into
|
|
||||||
the component definition
|
|
||||||
|
|
||||||
See :py:class:`jenkins_jobs.modules.base.Base` for how to register
|
|
||||||
components of a module.
|
|
||||||
|
|
||||||
See the Publishers module for a simple example of how to use
|
|
||||||
this method.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if component_type not in self.modules_by_component_type:
|
|
||||||
raise JenkinsJobsException("Unknown component type: "
|
|
||||||
"'{0}'.".format(component_type))
|
|
||||||
|
|
||||||
component_list_type = self.modules_by_component_type[component_type] \
|
|
||||||
.component_list_type
|
|
||||||
|
|
||||||
if isinstance(component, dict):
|
|
||||||
# The component is a singleton dictionary of name: dict(args)
|
|
||||||
name, component_data = next(iter(component.items()))
|
|
||||||
if template_data:
|
|
||||||
# Template data contains values that should be interpolated
|
|
||||||
# into the component definition
|
|
||||||
s = yaml.dump(component_data, default_flow_style=False)
|
|
||||||
allow_empty_variables = self.global_config \
|
|
||||||
and self.global_config.has_section('job_builder') \
|
|
||||||
and self.global_config.has_option(
|
|
||||||
'job_builder', 'allow_empty_variables') \
|
|
||||||
and self.global_config.getboolean(
|
|
||||||
'job_builder', 'allow_empty_variables')
|
|
||||||
s = CustomFormatter(
|
|
||||||
allow_empty_variables).format(s, **template_data)
|
|
||||||
component_data = yaml.load(s)
|
|
||||||
else:
|
|
||||||
# The component is a simple string name, eg "run-tests"
|
|
||||||
name = component
|
|
||||||
component_data = {}
|
|
||||||
|
|
||||||
# Look for a component function defined in an entry point
|
|
||||||
eps = ModuleRegistry.entry_points_cache.get(component_list_type)
|
|
||||||
if eps is None:
|
|
||||||
module_eps = list(pkg_resources.iter_entry_points(
|
|
||||||
group='jenkins_jobs.{0}'.format(component_list_type)))
|
|
||||||
eps = {}
|
|
||||||
for module_ep in module_eps:
|
|
||||||
if module_ep.name in eps:
|
|
||||||
raise JenkinsJobsException(
|
|
||||||
"Duplicate entry point found for component type: "
|
|
||||||
"'{0}', '{0}',"
|
|
||||||
"name: '{1}'".format(component_type, name))
|
|
||||||
eps[module_ep.name] = module_ep
|
|
||||||
|
|
||||||
ModuleRegistry.entry_points_cache[component_list_type] = eps
|
|
||||||
logger.debug("Cached entry point group %s = %s",
|
|
||||||
component_list_type, eps)
|
|
||||||
|
|
||||||
if name in eps:
|
|
||||||
func = eps[name].load()
|
|
||||||
func(parser, xml_parent, component_data)
|
|
||||||
else:
|
|
||||||
# Otherwise, see if it's defined as a macro
|
|
||||||
component = parser.data.get(component_type, {}).get(name)
|
|
||||||
if component:
|
|
||||||
for b in component[component_list_type]:
|
|
||||||
# Pass component_data in as template data to this function
|
|
||||||
# so that if the macro is invoked with arguments,
|
|
||||||
# the arguments are interpolated into the real defn.
|
|
||||||
self.dispatch(component_type,
|
|
||||||
parser, xml_parent, b, component_data)
|
|
||||||
else:
|
|
||||||
raise JenkinsJobsException("Unknown entry point or macro '{0}'"
|
|
||||||
" for component type: '{1}'.".
|
|
||||||
format(name, component_type))
|
|
||||||
|
|
||||||
|
|
||||||
class XmlJob(object):
|
|
||||||
def __init__(self, xml, name):
|
|
||||||
self.xml = xml
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def md5(self):
|
|
||||||
return hashlib.md5(self.output()).hexdigest()
|
|
||||||
|
|
||||||
def output(self):
|
|
||||||
out = minidom.parseString(XML.tostring(self.xml, encoding='UTF-8'))
|
|
||||||
return out.toprettyxml(indent=' ', encoding='utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
class CacheStorage(object):
|
class CacheStorage(object):
|
||||||
# ensure each instance of the class has a reference to the required
|
# ensure each instance of the class has a reference to the required
|
||||||
# modules so that they are available to be used when the destructor
|
# modules so that they are available to be used when the destructor
|
||||||
|
18
jenkins_jobs/constants.py
Normal file
18
jenkins_jobs/constants.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright (C) 2015 OpenStack, LLC.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Manage JJB Constants
|
||||||
|
|
||||||
|
MAGIC_MANAGE_STRING = "<!-- Managed by Jenkins Job Builder -->"
|
86
jenkins_jobs/formatter.py
Normal file
86
jenkins_jobs/formatter.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright (C) 2015 OpenStack, LLC.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Manage interpolation of JJB variables into template strings.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pprint import pformat
|
||||||
|
import re
|
||||||
|
from string import Formatter
|
||||||
|
|
||||||
|
from jenkins_jobs.errors import JenkinsJobsException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def deep_format(obj, paramdict, allow_empty=False):
|
||||||
|
"""Apply the paramdict via str.format() to all string objects found within
|
||||||
|
the supplied obj. Lists and dicts are traversed recursively."""
|
||||||
|
# YAML serialisation was originally used to achieve this, but that places
|
||||||
|
# limitations on the values in paramdict - the post-format result must
|
||||||
|
# still be valid YAML (so substituting-in a string containing quotes, for
|
||||||
|
# example, is problematic).
|
||||||
|
if hasattr(obj, 'format'):
|
||||||
|
try:
|
||||||
|
result = re.match('^{obj:(?P<key>\w+)}$', obj)
|
||||||
|
if result is not None:
|
||||||
|
ret = paramdict[result.group("key")]
|
||||||
|
else:
|
||||||
|
ret = CustomFormatter(allow_empty).format(obj, **paramdict)
|
||||||
|
except KeyError as exc:
|
||||||
|
missing_key = exc.message
|
||||||
|
desc = "%s parameter missing to format %s\nGiven:\n%s" % (
|
||||||
|
missing_key, obj, pformat(paramdict))
|
||||||
|
raise JenkinsJobsException(desc)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
ret = []
|
||||||
|
for item in obj:
|
||||||
|
ret.append(deep_format(item, paramdict, allow_empty))
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
ret = {}
|
||||||
|
for item in obj:
|
||||||
|
try:
|
||||||
|
ret[CustomFormatter(allow_empty).format(item, **paramdict)] = \
|
||||||
|
deep_format(obj[item], paramdict, allow_empty)
|
||||||
|
except KeyError as exc:
|
||||||
|
missing_key = exc.message
|
||||||
|
desc = "%s parameter missing to format %s\nGiven:\n%s" % (
|
||||||
|
missing_key, obj, pformat(paramdict))
|
||||||
|
raise JenkinsJobsException(desc)
|
||||||
|
else:
|
||||||
|
ret = obj
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Custom formatter to allow non-existing key references when formatting a
|
||||||
|
string
|
||||||
|
"""
|
||||||
|
def __init__(self, allow_empty=False):
|
||||||
|
super(CustomFormatter, self).__init__()
|
||||||
|
self.allow_empty = allow_empty
|
||||||
|
|
||||||
|
def get_value(self, key, args, kwargs):
|
||||||
|
try:
|
||||||
|
return Formatter.get_value(self, key, args, kwargs)
|
||||||
|
except KeyError:
|
||||||
|
if self.allow_empty:
|
||||||
|
logger.debug(
|
||||||
|
'Found uninitialized key %s, replaced with empty string',
|
||||||
|
key
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
raise
|
322
jenkins_jobs/parser.py
Normal file
322
jenkins_jobs/parser.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright (C) 2015 OpenStack, LLC.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Manage JJB yaml feature implementation
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fnmatch
|
||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
import jenkins_jobs.local_yaml as local_yaml
|
||||||
|
from jenkins_jobs.constants import MAGIC_MANAGE_STRING
|
||||||
|
from jenkins_jobs.errors import JenkinsJobsException
|
||||||
|
from jenkins_jobs.registry import ModuleRegistry
|
||||||
|
from jenkins_jobs.formatter import deep_format
|
||||||
|
from jenkins_jobs.xml_config import XmlJob
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def matches(what, glob_patterns):
|
||||||
|
"""
|
||||||
|
Checks if the given string, ``what``, matches any of the glob patterns in
|
||||||
|
the iterable, ``glob_patterns``
|
||||||
|
|
||||||
|
:arg str what: String that we want to test if it matches a pattern
|
||||||
|
:arg iterable glob_patterns: glob patterns to match (list, tuple, set,
|
||||||
|
etc.)
|
||||||
|
"""
|
||||||
|
return any(fnmatch.fnmatch(what, glob_pattern)
|
||||||
|
for glob_pattern in glob_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
class YamlParser(object):
|
||||||
|
def __init__(self, config=None, plugins_info=None):
|
||||||
|
self.data = {}
|
||||||
|
self.jobs = []
|
||||||
|
self.xml_jobs = []
|
||||||
|
self.config = config
|
||||||
|
self.registry = ModuleRegistry(self.config, plugins_info)
|
||||||
|
self.path = ["."]
|
||||||
|
if self.config:
|
||||||
|
if config.has_section('job_builder') and \
|
||||||
|
config.has_option('job_builder', 'include_path'):
|
||||||
|
self.path = config.get('job_builder',
|
||||||
|
'include_path').split(':')
|
||||||
|
self.keep_desc = self.get_keep_desc()
|
||||||
|
|
||||||
|
def get_keep_desc(self):
|
||||||
|
keep_desc = False
|
||||||
|
if self.config and self.config.has_section('job_builder') and \
|
||||||
|
self.config.has_option('job_builder', 'keep_descriptions'):
|
||||||
|
keep_desc = self.config.getboolean('job_builder',
|
||||||
|
'keep_descriptions')
|
||||||
|
return keep_desc
|
||||||
|
|
||||||
|
def parse_fp(self, fp):
|
||||||
|
data = local_yaml.load(fp, search_path=self.path)
|
||||||
|
if data:
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise JenkinsJobsException(
|
||||||
|
"The topmost collection in file '{fname}' must be a list,"
|
||||||
|
" not a {cls}".format(fname=getattr(fp, 'name', fp),
|
||||||
|
cls=type(data)))
|
||||||
|
for item in data:
|
||||||
|
cls, dfn = next(iter(item.items()))
|
||||||
|
group = self.data.get(cls, {})
|
||||||
|
if len(item.items()) > 1:
|
||||||
|
n = None
|
||||||
|
for k, v in item.items():
|
||||||
|
if k == "name":
|
||||||
|
n = v
|
||||||
|
break
|
||||||
|
# Syntax error
|
||||||
|
raise JenkinsJobsException("Syntax error, for item "
|
||||||
|
"named '{0}'. Missing indent?"
|
||||||
|
.format(n))
|
||||||
|
name = dfn['name']
|
||||||
|
if name in group:
|
||||||
|
self._handle_dups("Duplicate entry found in '{0}: '{1}' "
|
||||||
|
"already defined".format(fp.name, name))
|
||||||
|
group[name] = dfn
|
||||||
|
self.data[cls] = group
|
||||||
|
|
||||||
|
def parse(self, fn):
|
||||||
|
with open(fn) as fp:
|
||||||
|
self.parse_fp(fp)
|
||||||
|
|
||||||
|
def _handle_dups(self, message):
|
||||||
|
|
||||||
|
if not (self.config and self.config.has_section('job_builder') and
|
||||||
|
self.config.getboolean('job_builder', 'allow_duplicates')):
|
||||||
|
logger.error(message)
|
||||||
|
raise JenkinsJobsException(message)
|
||||||
|
else:
|
||||||
|
logger.warn(message)
|
||||||
|
|
||||||
|
def getJob(self, name):
|
||||||
|
job = self.data.get('job', {}).get(name, None)
|
||||||
|
if not job:
|
||||||
|
return job
|
||||||
|
return self.applyDefaults(job)
|
||||||
|
|
||||||
|
def getJobGroup(self, name):
|
||||||
|
return self.data.get('job-group', {}).get(name, None)
|
||||||
|
|
||||||
|
def getJobTemplate(self, name):
|
||||||
|
job = self.data.get('job-template', {}).get(name, None)
|
||||||
|
if not job:
|
||||||
|
return job
|
||||||
|
return self.applyDefaults(job)
|
||||||
|
|
||||||
|
def applyDefaults(self, data, override_dict=None):
|
||||||
|
if override_dict is None:
|
||||||
|
override_dict = {}
|
||||||
|
|
||||||
|
whichdefaults = data.get('defaults', 'global')
|
||||||
|
defaults = copy.deepcopy(self.data.get('defaults',
|
||||||
|
{}).get(whichdefaults, {}))
|
||||||
|
if defaults == {} and whichdefaults != 'global':
|
||||||
|
raise JenkinsJobsException("Unknown defaults set: '{0}'"
|
||||||
|
.format(whichdefaults))
|
||||||
|
|
||||||
|
for key in override_dict.keys():
|
||||||
|
if key in defaults.keys():
|
||||||
|
defaults[key] = override_dict[key]
|
||||||
|
|
||||||
|
newdata = {}
|
||||||
|
newdata.update(defaults)
|
||||||
|
newdata.update(data)
|
||||||
|
return newdata
|
||||||
|
|
||||||
|
def formatDescription(self, job):
|
||||||
|
if self.keep_desc:
|
||||||
|
description = job.get("description", None)
|
||||||
|
else:
|
||||||
|
description = job.get("description", '')
|
||||||
|
if description is not None:
|
||||||
|
job["description"] = description + \
|
||||||
|
self.get_managed_string().lstrip()
|
||||||
|
|
||||||
|
def expandYaml(self, jobs_glob=None):
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for module in self.registry.modules:
|
||||||
|
if hasattr(module, 'handle_data'):
|
||||||
|
if module.handle_data(self):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for job in self.data.get('job', {}).values():
|
||||||
|
if jobs_glob and not matches(job['name'], jobs_glob):
|
||||||
|
logger.debug("Ignoring job {0}".format(job['name']))
|
||||||
|
continue
|
||||||
|
logger.debug("Expanding job '{0}'".format(job['name']))
|
||||||
|
job = self.applyDefaults(job)
|
||||||
|
self.formatDescription(job)
|
||||||
|
self.jobs.append(job)
|
||||||
|
for project in self.data.get('project', {}).values():
|
||||||
|
logger.debug("Expanding project '{0}'".format(project['name']))
|
||||||
|
# use a set to check for duplicate job references in projects
|
||||||
|
seen = set()
|
||||||
|
for jobspec in project.get('jobs', []):
|
||||||
|
if isinstance(jobspec, dict):
|
||||||
|
# Singleton dict containing dict of job-specific params
|
||||||
|
jobname, jobparams = next(iter(jobspec.items()))
|
||||||
|
if not isinstance(jobparams, dict):
|
||||||
|
jobparams = {}
|
||||||
|
else:
|
||||||
|
jobname = jobspec
|
||||||
|
jobparams = {}
|
||||||
|
job = self.getJob(jobname)
|
||||||
|
if job:
|
||||||
|
# Just naming an existing defined job
|
||||||
|
if jobname in seen:
|
||||||
|
self._handle_dups("Duplicate job '{0}' specified "
|
||||||
|
"for project '{1}'".format(
|
||||||
|
jobname, project['name']))
|
||||||
|
seen.add(jobname)
|
||||||
|
continue
|
||||||
|
# see if it's a job group
|
||||||
|
group = self.getJobGroup(jobname)
|
||||||
|
if group:
|
||||||
|
for group_jobspec in group['jobs']:
|
||||||
|
if isinstance(group_jobspec, dict):
|
||||||
|
group_jobname, group_jobparams = \
|
||||||
|
next(iter(group_jobspec.items()))
|
||||||
|
if not isinstance(group_jobparams, dict):
|
||||||
|
group_jobparams = {}
|
||||||
|
else:
|
||||||
|
group_jobname = group_jobspec
|
||||||
|
group_jobparams = {}
|
||||||
|
job = self.getJob(group_jobname)
|
||||||
|
if job:
|
||||||
|
if group_jobname in seen:
|
||||||
|
self._handle_dups(
|
||||||
|
"Duplicate job '{0}' specified for "
|
||||||
|
"project '{1}'".format(group_jobname,
|
||||||
|
project['name']))
|
||||||
|
seen.add(group_jobname)
|
||||||
|
continue
|
||||||
|
template = self.getJobTemplate(group_jobname)
|
||||||
|
# Allow a group to override parameters set by a project
|
||||||
|
d = {}
|
||||||
|
d.update(project)
|
||||||
|
d.update(jobparams)
|
||||||
|
d.update(group)
|
||||||
|
d.update(group_jobparams)
|
||||||
|
# Except name, since the group's name is not useful
|
||||||
|
d['name'] = project['name']
|
||||||
|
if template:
|
||||||
|
self.expandYamlForTemplateJob(d, template,
|
||||||
|
jobs_glob)
|
||||||
|
continue
|
||||||
|
# see if it's a template
|
||||||
|
template = self.getJobTemplate(jobname)
|
||||||
|
if template:
|
||||||
|
d = {}
|
||||||
|
d.update(project)
|
||||||
|
d.update(jobparams)
|
||||||
|
self.expandYamlForTemplateJob(d, template, jobs_glob)
|
||||||
|
else:
|
||||||
|
raise JenkinsJobsException("Failed to find suitable "
|
||||||
|
"template named '{0}'"
|
||||||
|
.format(jobname))
|
||||||
|
# check for duplicate generated jobs
|
||||||
|
seen = set()
|
||||||
|
# walk the list in reverse so that last definition wins
|
||||||
|
for job in self.jobs[::-1]:
|
||||||
|
if job['name'] in seen:
|
||||||
|
self._handle_dups("Duplicate definitions for job '{0}' "
|
||||||
|
"specified".format(job['name']))
|
||||||
|
self.jobs.remove(job)
|
||||||
|
seen.add(job['name'])
|
||||||
|
|
||||||
|
def expandYamlForTemplateJob(self, project, template, jobs_glob=None):
|
||||||
|
dimensions = []
|
||||||
|
template_name = template['name']
|
||||||
|
# reject keys that are not useful during yaml expansion
|
||||||
|
for k in ['jobs']:
|
||||||
|
project.pop(k)
|
||||||
|
for (k, v) in project.items():
|
||||||
|
tmpk = '{{{0}}}'.format(k)
|
||||||
|
if tmpk not in template_name:
|
||||||
|
logger.debug("Variable %s not in name %s, rejecting from job"
|
||||||
|
" matrix expansion.", tmpk, template_name)
|
||||||
|
continue
|
||||||
|
if type(v) == list:
|
||||||
|
dimensions.append(zip([k] * len(v), v))
|
||||||
|
# XXX somewhat hackish to ensure we actually have a single
|
||||||
|
# pass through the loop
|
||||||
|
if len(dimensions) == 0:
|
||||||
|
dimensions = [(("", ""),)]
|
||||||
|
|
||||||
|
for values in itertools.product(*dimensions):
|
||||||
|
params = copy.deepcopy(project)
|
||||||
|
params = self.applyDefaults(params, template)
|
||||||
|
|
||||||
|
expanded_values = {}
|
||||||
|
for (k, v) in values:
|
||||||
|
if isinstance(v, dict):
|
||||||
|
inner_key = next(iter(v))
|
||||||
|
expanded_values[k] = inner_key
|
||||||
|
expanded_values.update(v[inner_key])
|
||||||
|
else:
|
||||||
|
expanded_values[k] = v
|
||||||
|
|
||||||
|
params.update(expanded_values)
|
||||||
|
params = deep_format(params, params)
|
||||||
|
allow_empty_variables = self.config \
|
||||||
|
and self.config.has_section('job_builder') \
|
||||||
|
and self.config.has_option(
|
||||||
|
'job_builder', 'allow_empty_variables') \
|
||||||
|
and self.config.getboolean(
|
||||||
|
'job_builder', 'allow_empty_variables')
|
||||||
|
expanded = deep_format(template, params, allow_empty_variables)
|
||||||
|
|
||||||
|
job_name = expanded.get('name')
|
||||||
|
if jobs_glob and not matches(job_name, jobs_glob):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.formatDescription(expanded)
|
||||||
|
self.jobs.append(expanded)
|
||||||
|
|
||||||
|
def get_managed_string(self):
|
||||||
|
# The \n\n is not hard coded, because they get stripped if the
|
||||||
|
# project does not otherwise have a description.
|
||||||
|
return "\n\n" + MAGIC_MANAGE_STRING
|
||||||
|
|
||||||
|
def generateXML(self):
|
||||||
|
for job in self.jobs:
|
||||||
|
self.xml_jobs.append(self.getXMLForJob(job))
|
||||||
|
|
||||||
|
def getXMLForJob(self, data):
|
||||||
|
kind = data.get('project-type', 'freestyle')
|
||||||
|
|
||||||
|
for ep in pkg_resources.iter_entry_points(
|
||||||
|
group='jenkins_jobs.projects', name=kind):
|
||||||
|
Mod = ep.load()
|
||||||
|
mod = Mod(self.registry)
|
||||||
|
xml = mod.root_xml(data)
|
||||||
|
self.gen_xml(xml, data)
|
||||||
|
job = XmlJob(xml, data['name'])
|
||||||
|
return job
|
||||||
|
|
||||||
|
def gen_xml(self, xml, data):
|
||||||
|
for module in self.registry.modules:
|
||||||
|
if hasattr(module, 'gen_xml'):
|
||||||
|
module.gen_xml(self, xml, data)
|
204
jenkins_jobs/registry.py
Normal file
204
jenkins_jobs/registry.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright (C) 2015 OpenStack, LLC.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Manage Jenkins plugin module registry.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import operator
|
||||||
|
import pkg_resources
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from jenkins_jobs.errors import JenkinsJobsException
|
||||||
|
from jenkins_jobs.formatter import CustomFormatter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleRegistry(object):
|
||||||
|
entry_points_cache = {}
|
||||||
|
|
||||||
|
def __init__(self, config, plugins_list=None):
|
||||||
|
self.modules = []
|
||||||
|
self.modules_by_component_type = {}
|
||||||
|
self.handlers = {}
|
||||||
|
self.global_config = config
|
||||||
|
|
||||||
|
if plugins_list is None:
|
||||||
|
self.plugins_dict = {}
|
||||||
|
else:
|
||||||
|
self.plugins_dict = self._get_plugins_info_dict(plugins_list)
|
||||||
|
|
||||||
|
for entrypoint in pkg_resources.iter_entry_points(
|
||||||
|
group='jenkins_jobs.modules'):
|
||||||
|
Mod = entrypoint.load()
|
||||||
|
mod = Mod(self)
|
||||||
|
self.modules.append(mod)
|
||||||
|
self.modules.sort(key=operator.attrgetter('sequence'))
|
||||||
|
if mod.component_type is not None:
|
||||||
|
self.modules_by_component_type[mod.component_type] = mod
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_plugins_info_dict(plugins_list):
|
||||||
|
def mutate_plugin_info(plugin_info):
|
||||||
|
"""
|
||||||
|
We perform mutations on a single member of plugin_info here, then
|
||||||
|
return a dictionary with the longName and shortName of the plugin
|
||||||
|
mapped to its plugin info dictionary.
|
||||||
|
"""
|
||||||
|
version = plugin_info.get('version', '0')
|
||||||
|
plugin_info['version'] = re.sub(r'(.*)-(?:SNAPSHOT|BETA)',
|
||||||
|
r'\g<1>.preview', version)
|
||||||
|
|
||||||
|
aliases = []
|
||||||
|
for key in ['longName', 'shortName']:
|
||||||
|
value = plugin_info.get(key, None)
|
||||||
|
if value is not None:
|
||||||
|
aliases.append(value)
|
||||||
|
|
||||||
|
plugin_info_dict = {}
|
||||||
|
for name in aliases:
|
||||||
|
plugin_info_dict[name] = plugin_info
|
||||||
|
|
||||||
|
return plugin_info_dict
|
||||||
|
|
||||||
|
list_of_dicts = [mutate_plugin_info(v) for v in plugins_list]
|
||||||
|
|
||||||
|
plugins_info_dict = {}
|
||||||
|
for d in list_of_dicts:
|
||||||
|
plugins_info_dict.update(d)
|
||||||
|
|
||||||
|
return plugins_info_dict
|
||||||
|
|
||||||
|
def get_plugin_info(self, plugin_name):
|
||||||
|
""" This method is intended to provide information about plugins within
|
||||||
|
a given module's implementation of Base.gen_xml. The return value is a
|
||||||
|
dictionary with data obtained directly from a running Jenkins instance.
|
||||||
|
This allows module authors to differentiate generated XML output based
|
||||||
|
on information such as specific plugin versions.
|
||||||
|
|
||||||
|
:arg string plugin_name: Either the shortName or longName of a plugin
|
||||||
|
as see in a query that looks like:
|
||||||
|
``http://<jenkins-hostname>/pluginManager/api/json?pretty&depth=2``
|
||||||
|
|
||||||
|
During a 'test' run, it is possible to override JJB's query to a live
|
||||||
|
Jenkins instance by passing it a path to a file containing a YAML list
|
||||||
|
of dictionaries that mimics the plugin properties you want your test
|
||||||
|
output to reflect::
|
||||||
|
|
||||||
|
jenkins-jobs test -p /path/to/plugins-info.yaml
|
||||||
|
|
||||||
|
Below is example YAML that might be included in
|
||||||
|
/path/to/plugins-info.yaml.
|
||||||
|
|
||||||
|
.. literalinclude:: /../../tests/cmd/fixtures/plugins-info.yaml
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.plugins_dict.get(plugin_name, {})
|
||||||
|
|
||||||
|
def registerHandler(self, category, name, method):
|
||||||
|
cat_dict = self.handlers.get(category, {})
|
||||||
|
if not cat_dict:
|
||||||
|
self.handlers[category] = cat_dict
|
||||||
|
cat_dict[name] = method
|
||||||
|
|
||||||
|
def getHandler(self, category, name):
|
||||||
|
return self.handlers[category][name]
|
||||||
|
|
||||||
|
def dispatch(self, component_type,
|
||||||
|
parser, xml_parent,
|
||||||
|
component, template_data={}):
|
||||||
|
"""This is a method that you can call from your implementation of
|
||||||
|
Base.gen_xml or component. It allows modules to define a type
|
||||||
|
of component, and benefit from extensibility via Python
|
||||||
|
entry points and Jenkins Job Builder :ref:`Macros <macro>`.
|
||||||
|
|
||||||
|
:arg string component_type: the name of the component
|
||||||
|
(e.g., `builder`)
|
||||||
|
:arg YAMLParser parser: the global YAML Parser
|
||||||
|
:arg Element xml_parent: the parent XML element
|
||||||
|
:arg dict template_data: values that should be interpolated into
|
||||||
|
the component definition
|
||||||
|
|
||||||
|
See :py:class:`jenkins_jobs.modules.base.Base` for how to register
|
||||||
|
components of a module.
|
||||||
|
|
||||||
|
See the Publishers module for a simple example of how to use
|
||||||
|
this method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if component_type not in self.modules_by_component_type:
|
||||||
|
raise JenkinsJobsException("Unknown component type: "
|
||||||
|
"'{0}'.".format(component_type))
|
||||||
|
|
||||||
|
component_list_type = self.modules_by_component_type[component_type] \
|
||||||
|
.component_list_type
|
||||||
|
|
||||||
|
if isinstance(component, dict):
|
||||||
|
# The component is a singleton dictionary of name: dict(args)
|
||||||
|
name, component_data = next(iter(component.items()))
|
||||||
|
if template_data:
|
||||||
|
# Template data contains values that should be interpolated
|
||||||
|
# into the component definition
|
||||||
|
s = yaml.dump(component_data, default_flow_style=False)
|
||||||
|
allow_empty_variables = self.global_config \
|
||||||
|
and self.global_config.has_section('job_builder') \
|
||||||
|
and self.global_config.has_option(
|
||||||
|
'job_builder', 'allow_empty_variables') \
|
||||||
|
and self.global_config.getboolean(
|
||||||
|
'job_builder', 'allow_empty_variables')
|
||||||
|
s = CustomFormatter(
|
||||||
|
allow_empty_variables).format(s, **template_data)
|
||||||
|
component_data = yaml.load(s)
|
||||||
|
else:
|
||||||
|
# The component is a simple string name, eg "run-tests"
|
||||||
|
name = component
|
||||||
|
component_data = {}
|
||||||
|
|
||||||
|
# Look for a component function defined in an entry point
|
||||||
|
eps = ModuleRegistry.entry_points_cache.get(component_list_type)
|
||||||
|
if eps is None:
|
||||||
|
module_eps = list(pkg_resources.iter_entry_points(
|
||||||
|
group='jenkins_jobs.{0}'.format(component_list_type)))
|
||||||
|
eps = {}
|
||||||
|
for module_ep in module_eps:
|
||||||
|
if module_ep.name in eps:
|
||||||
|
raise JenkinsJobsException(
|
||||||
|
"Duplicate entry point found for component type: "
|
||||||
|
"'{0}', '{0}',"
|
||||||
|
"name: '{1}'".format(component_type, name))
|
||||||
|
eps[module_ep.name] = module_ep
|
||||||
|
|
||||||
|
ModuleRegistry.entry_points_cache[component_list_type] = eps
|
||||||
|
logger.debug("Cached entry point group %s = %s",
|
||||||
|
component_list_type, eps)
|
||||||
|
|
||||||
|
if name in eps:
|
||||||
|
func = eps[name].load()
|
||||||
|
func(parser, xml_parent, component_data)
|
||||||
|
else:
|
||||||
|
# Otherwise, see if it's defined as a macro
|
||||||
|
component = parser.data.get(component_type, {}).get(name)
|
||||||
|
if component:
|
||||||
|
for b in component[component_list_type]:
|
||||||
|
# Pass component_data in as template data to this function
|
||||||
|
# so that if the macro is invoked with arguments,
|
||||||
|
# the arguments are interpolated into the real defn.
|
||||||
|
self.dispatch(component_type,
|
||||||
|
parser, xml_parent, b, component_data)
|
||||||
|
else:
|
||||||
|
raise JenkinsJobsException("Unknown entry point or macro '{0}'"
|
||||||
|
" for component type: '{1}'.".
|
||||||
|
format(name, component_type))
|
33
jenkins_jobs/xml_config.py
Normal file
33
jenkins_jobs/xml_config.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Copyright (C) 2015 OpenStack, LLC.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
# Manage Jenkins XML config file output.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from xml.dom import minidom
|
||||||
|
import xml.etree.ElementTree as XML
|
||||||
|
|
||||||
|
|
||||||
|
class XmlJob(object):
|
||||||
|
def __init__(self, xml, name):
|
||||||
|
self.xml = xml
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def md5(self):
|
||||||
|
return hashlib.md5(self.output()).hexdigest()
|
||||||
|
|
||||||
|
def output(self):
|
||||||
|
out = minidom.parseString(XML.tostring(self.xml, encoding='UTF-8'))
|
||||||
|
return out.toprettyxml(indent=' ', encoding='utf-8')
|
@ -37,7 +37,8 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
import mock # noqa
|
import mock # noqa
|
||||||
import jenkins_jobs.local_yaml as yaml
|
import jenkins_jobs.local_yaml as yaml
|
||||||
from jenkins_jobs.builder import XmlJob, YamlParser
|
from jenkins_jobs.parser import YamlParser
|
||||||
|
from jenkins_jobs.xml_config import XmlJob
|
||||||
from jenkins_jobs.modules import (project_flow,
|
from jenkins_jobs.modules import (project_flow,
|
||||||
project_matrix,
|
project_matrix,
|
||||||
project_maven,
|
project_maven,
|
||||||
|
@ -190,7 +190,7 @@ class TestTests(CmdTestsBase):
|
|||||||
"http://test-jenkins.with.non.default.url:8080/")
|
"http://test-jenkins.with.non.default.url:8080/")
|
||||||
|
|
||||||
@mock.patch('jenkins_jobs.builder.YamlParser.generateXML')
|
@mock.patch('jenkins_jobs.builder.YamlParser.generateXML')
|
||||||
@mock.patch('jenkins_jobs.builder.ModuleRegistry')
|
@mock.patch('jenkins_jobs.parser.ModuleRegistry')
|
||||||
def test_plugins_info_stub_option(self, registry_mock, generateXML_mock):
|
def test_plugins_info_stub_option(self, registry_mock, generateXML_mock):
|
||||||
"""
|
"""
|
||||||
Test handling of plugins_info stub option.
|
Test handling of plugins_info stub option.
|
||||||
@ -214,7 +214,7 @@ class TestTests(CmdTestsBase):
|
|||||||
registry_mock.assert_called_with(self.config, plugins_info_list)
|
registry_mock.assert_called_with(self.config, plugins_info_list)
|
||||||
|
|
||||||
@mock.patch('jenkins_jobs.builder.YamlParser.generateXML')
|
@mock.patch('jenkins_jobs.builder.YamlParser.generateXML')
|
||||||
@mock.patch('jenkins_jobs.builder.ModuleRegistry')
|
@mock.patch('jenkins_jobs.parser.ModuleRegistry')
|
||||||
def test_bogus_plugins_info_stub_option(self, registry_mock,
|
def test_bogus_plugins_info_stub_option(self, registry_mock,
|
||||||
generateXML_mock):
|
generateXML_mock):
|
||||||
"""
|
"""
|
||||||
|
@ -5,7 +5,7 @@ from testscenarios.testcase import TestWithScenarios
|
|||||||
from six.moves import configparser, StringIO
|
from six.moves import configparser, StringIO
|
||||||
|
|
||||||
from jenkins_jobs import cmd
|
from jenkins_jobs import cmd
|
||||||
from jenkins_jobs.builder import ModuleRegistry
|
from jenkins_jobs.registry import ModuleRegistry
|
||||||
|
|
||||||
|
|
||||||
class ModuleRegistryPluginInfoTestsWithScenarios(TestWithScenarios,
|
class ModuleRegistryPluginInfoTestsWithScenarios(TestWithScenarios,
|
||||||
|
Loading…
Reference in New Issue
Block a user