mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
implement lookup plugins for arbitrary enumeration over arbitrary things. See the mailing list for some cool examples.
This commit is contained in:
parent
29d49d415f
commit
c5d2f6b0d3
14 changed files with 135 additions and 28 deletions
|
@ -8,6 +8,7 @@ Highlighted Core Changes:
|
|||
* fireball mode -- ansible can bootstrap a ephemeral 0mq (zeromq) daemon that runs as a given user and expires after X period of time. It is very fast.
|
||||
* playbooks with errors now return 2 on failure. 1 indicates a more fatal syntax error. Similar for /usr/bin/ansible
|
||||
* server side action code (template, etc) are now fully pluggable
|
||||
* ability to write lookup plugins, like the code powering "with_fileglob" (see below)
|
||||
|
||||
Other Core Changes:
|
||||
|
||||
|
@ -44,6 +45,7 @@ Highlighted playbook changes:
|
|||
* when using a $list variable with $var or ${var} syntax it will automatically join with commas
|
||||
* setup is not run more than once when we know it is has already been run in a play that included another play, etc
|
||||
* can set/override sudo and sudo_user on individual tasks in a play, defaults to what is set in the play if not present
|
||||
* ability to use with_fileglob to iterate over local file patterns
|
||||
|
||||
Other playbook changes:
|
||||
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -26,6 +26,8 @@ from play import Play
|
|||
|
||||
SETUP_CACHE = collections.defaultdict(dict)
|
||||
|
||||
plugins_dir = os.path.join(os.path.dirname(__file__), '..', 'runner')
|
||||
|
||||
class PlayBook(object):
|
||||
'''
|
||||
runs an ansible playbook, given as a datastructure or YAML filename.
|
||||
|
@ -105,9 +107,12 @@ class PlayBook(object):
|
|||
self.private_key_file = private_key_file
|
||||
self.only_tags = only_tags
|
||||
|
||||
self.inventory = ansible.inventory.Inventory(host_list)
|
||||
self.inventory = ansible.inventory.Inventory(host_list)
|
||||
self.inventory.subset(subset)
|
||||
self.modules_list = utils.get_available_modules(self.module_path)
|
||||
|
||||
self.modules_list = utils.get_available_modules(self.module_path)
|
||||
lookup_plugins_dir = os.path.join(plugins_dir, 'lookup_plugins')
|
||||
self.lookup_plugins_list = utils.import_plugins(lookup_plugins_dir)
|
||||
|
||||
if not self.inventory._is_script:
|
||||
self.global_vars.update(self.inventory.get_group_variables('all'))
|
||||
|
|
|
@ -26,7 +26,8 @@ class Task(object):
|
|||
'notify', 'module_name', 'module_args', 'module_vars',
|
||||
'play', 'notified_by', 'tags', 'register', 'with_items',
|
||||
'delegate_to', 'first_available_file', 'ignore_errors',
|
||||
'local_action', 'transport', 'sudo', 'sudo_user', 'sudo_pass'
|
||||
'local_action', 'transport', 'sudo', 'sudo_user', 'sudo_pass',
|
||||
'items_lookup_plugin', 'items_lookup_terms'
|
||||
]
|
||||
|
||||
# to prevent typos and such
|
||||
|
@ -40,11 +41,23 @@ class Task(object):
|
|||
def __init__(self, play, ds, module_vars=None):
|
||||
''' constructor loads from a task or handler datastructure '''
|
||||
|
||||
# code to allow for saying "modulename: args" versus "action: modulename args"
|
||||
for x in ds.keys():
|
||||
|
||||
# code to allow for saying "modulename: args" versus "action: modulename args"
|
||||
if x in play.playbook.modules_list:
|
||||
ds['action'] = x + " " + ds.get(x, None)
|
||||
ds['action'] = x + " " + ds[x]
|
||||
ds.pop(x)
|
||||
|
||||
# code to allow "with_glob" and to reference a lookup plugin named glob
|
||||
elif x.startswith("with_") and x != 'with_items':
|
||||
plugin_name = x.replace("with_","")
|
||||
if plugin_name in play.playbook.lookup_plugins_list:
|
||||
ds['items_lookup_plugin'] = plugin_name
|
||||
ds['items_lookup_terms'] = ds[x]
|
||||
ds.pop(x)
|
||||
else:
|
||||
raise errors.AnsibleError("cannot find lookup plugin named %s for usage in with_%s" % (plugin_name, plugin_name))
|
||||
|
||||
elif not x in Task.VALID_KEYS:
|
||||
raise errors.AnsibleError("%s is not a legal parameter in an Ansible task or handler" % x)
|
||||
|
||||
|
@ -101,6 +114,10 @@ class Task(object):
|
|||
self.first_available_file = ds.get('first_available_file', None)
|
||||
self.with_items = ds.get('with_items', None)
|
||||
|
||||
self.items_lookup_plugin = ds.get('items_lookup_plugin', None)
|
||||
self.items_lookup_terms = ds.get('items_lookup_terms', None)
|
||||
|
||||
|
||||
self.ignore_errors = ds.get('ignore_errors', False)
|
||||
|
||||
# notify can be a string or a list, store as a list
|
||||
|
@ -125,8 +142,9 @@ class Task(object):
|
|||
self.action = utils.template(None, self.action, self.module_vars)
|
||||
|
||||
# handle mutually incompatible options
|
||||
if self.with_items is not None and self.first_available_file is not None:
|
||||
raise errors.AnsibleError("with_items and first_available_file are mutually incompatible in a single task")
|
||||
incompatibles = [ x for x in [ self.with_items, self.first_available_file, self.items_lookup_plugin ] if x is not None ]
|
||||
if len(incompatibles) > 1:
|
||||
raise errors.AnsibleError("with_items, with_(plugin), and first_available_file are mutually incompatible in a single task")
|
||||
|
||||
# make first_available_file accessable to Runner code
|
||||
if self.first_available_file:
|
||||
|
@ -137,6 +155,10 @@ class Task(object):
|
|||
self.with_items = [ ]
|
||||
self.module_vars['items'] = self.with_items
|
||||
|
||||
if self.items_lookup_plugin is not None:
|
||||
self.module_vars['items_lookup_plugin'] = self.items_lookup_plugin
|
||||
self.module_vars['items_lookup_terms'] = self.items_lookup_terms
|
||||
|
||||
# allow runner to see delegate_to option
|
||||
self.module_vars['delegate_to'] = self.delegate_to
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ except ImportError:
|
|||
|
||||
dirname = os.path.dirname(__file__)
|
||||
action_plugin_list = utils.import_plugins(os.path.join(dirname, 'action_plugins'))
|
||||
|
||||
lookup_plugin_list = utils.import_plugins(os.path.join(dirname, 'lookup_plugins'))
|
||||
|
||||
################################################
|
||||
|
||||
|
@ -71,8 +71,8 @@ def _executor_hook(job_queue, result_queue):
|
|||
traceback.print_exc()
|
||||
|
||||
class HostVars(dict):
|
||||
''' A special view of setup_cache that adds values from the inventory
|
||||
when needed. '''
|
||||
''' A special view of setup_cache that adds values from the inventory when needed. '''
|
||||
|
||||
def __init__(self, setup_cache, inventory):
|
||||
self.setup_cache = setup_cache
|
||||
self.inventory = inventory
|
||||
|
@ -82,9 +82,10 @@ class HostVars(dict):
|
|||
|
||||
def __getitem__(self, host):
|
||||
if not host in self.lookup:
|
||||
self.lookup[host] = self.inventory.get_variables(host)
|
||||
self.setup_cache[host].update(self.lookup[host])
|
||||
return self.setup_cache[host]
|
||||
result = self.inventory.get_variables(host)
|
||||
result.update(self.setup_cache.get(host, {}))
|
||||
self.lookup[host] = result
|
||||
return self.lookup[host]
|
||||
|
||||
class Runner(object):
|
||||
''' core API interface to ansible '''
|
||||
|
@ -160,8 +161,11 @@ class Runner(object):
|
|||
|
||||
# instantiate plugin classes
|
||||
self.action_plugins = {}
|
||||
self.lookup_plugins = {}
|
||||
for (k,v) in action_plugin_list.iteritems():
|
||||
self.action_plugins[k] = v.ActionModule(self)
|
||||
for (k,v) in lookup_plugin_list.iteritems():
|
||||
self.lookup_plugins[k] = v.LookupModule(self)
|
||||
|
||||
# *****************************************************
|
||||
|
||||
|
@ -189,7 +193,10 @@ class Runner(object):
|
|||
|
||||
afd, afile = tempfile.mkstemp()
|
||||
afo = os.fdopen(afd, 'w')
|
||||
afo.write(data.encode('utf8'))
|
||||
try:
|
||||
afo.write(data.encode('utf8'))
|
||||
except:
|
||||
raise errors.AnsibleError("failure encoding into utf-8")
|
||||
afo.flush()
|
||||
afo.close()
|
||||
|
||||
|
@ -283,10 +290,18 @@ class Runner(object):
|
|||
|
||||
# allow with_items to work in playbooks...
|
||||
# apt and yum are converted into a single call, others run in a loop
|
||||
|
||||
items = self.module_vars.get('items', [])
|
||||
if isinstance(items, basestring) and items.startswith("$"):
|
||||
items = utils.varReplaceWithItems(self.basedir, items, inject)
|
||||
|
||||
# if we instead said 'with_foo' and there is a lookup module named foo...
|
||||
items_plugin = self.module_vars.get('items_lookup_plugin', None)
|
||||
if items_plugin is not None:
|
||||
items_terms = self.module_vars.get('items_lookup_terms', '')
|
||||
if items_plugin in self.lookup_plugins:
|
||||
items_terms = utils.template(self.basedir, items_terms, inject)
|
||||
items = self.lookup_plugins[items_plugin].run(items_terms)
|
||||
|
||||
if type(items) != list:
|
||||
raise errors.AnsibleError("with_items only takes a list: %s" % items)
|
||||
|
||||
|
@ -313,6 +328,7 @@ class Runner(object):
|
|||
results.append(result.result)
|
||||
if result.comm_ok == False:
|
||||
all_comm_ok = False
|
||||
all_failed = True
|
||||
break
|
||||
for x in results:
|
||||
if x.get('changed') == True:
|
||||
|
@ -320,7 +336,7 @@ class Runner(object):
|
|||
if (x.get('failed') == True) or (('rc' in x) and (x['rc'] != 0)):
|
||||
all_failed = True
|
||||
break
|
||||
msg = 'All items succeeded'
|
||||
msg = 'All items completed'
|
||||
if all_failed:
|
||||
msg = "One or more items failed."
|
||||
rd_result = dict(failed=all_failed, changed=all_changed, results=results, msg=msg)
|
||||
|
|
|
@ -39,6 +39,11 @@ class ActionModule(object):
|
|||
options = utils.parse_kv(module_args)
|
||||
source = options.get('src', None)
|
||||
dest = options.get('dest', None)
|
||||
|
||||
if dest.endswith("/"):
|
||||
base = os.path.basename(source)
|
||||
dest = os.path.join(dest, base)
|
||||
|
||||
if (source is None and not 'first_available_file' in inject) or dest is None:
|
||||
result=dict(failed=True, msg="src and dest are required")
|
||||
return ReturnData(conn=conn, result=result)
|
||||
|
@ -78,10 +83,16 @@ class ActionModule(object):
|
|||
|
||||
# run the copy module
|
||||
module_args = "%s src=%s" % (module_args, tmp_src)
|
||||
print "CALLING FILE WITH: %s" % module_args
|
||||
return self.runner._execute_module(conn, tmp, 'copy', module_args, inject=inject).daisychain('file', module_args)
|
||||
|
||||
else:
|
||||
# no need to transfer the file, already correct md5
|
||||
# no need to transfer the file, already correct md5, but still need to set src so the file module
|
||||
# does not freak out. It's just the basename of the file.
|
||||
|
||||
tmp_src = tmp + os.path.basename(source)
|
||||
module_args = "%s src=%s" % (module_args, tmp_src)
|
||||
result = dict(changed=False, md5sum=remote_md5, transferred=False)
|
||||
print "CALLING FILE WITH: %s" % module_args
|
||||
return ReturnData(conn=conn, result=result).daisychain('file', module_args)
|
||||
|
||||
|
|
|
@ -42,6 +42,11 @@ class ActionModule(object):
|
|||
options = utils.parse_kv(module_args)
|
||||
source = options.get('src', None)
|
||||
dest = options.get('dest', None)
|
||||
|
||||
if dest.endswith("/"):
|
||||
base = os.path.basename(source)
|
||||
dest = os.path.join(dest, base)
|
||||
|
||||
if (source is None and 'first_available_file' not in inject) or dest is None:
|
||||
result = dict(failed=True, msg="src and dest are required")
|
||||
return ReturnData(conn=conn, comm_ok=False, result=result)
|
||||
|
|
0
lib/ansible/runner/lookup_plugins/__init__.py
Normal file
0
lib/ansible/runner/lookup_plugins/__init__.py
Normal file
30
lib/ansible/runner/lookup_plugins/fileglob.py
Normal file
30
lib/ansible/runner/lookup_plugins/fileglob.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
|
||||
#
|
||||
# This file is part of Ansible
|
||||
#
|
||||
# Ansible is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Ansible is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import glob
|
||||
|
||||
class LookupModule(object):
|
||||
|
||||
def __init__(self, runner):
|
||||
self.runner = runner
|
||||
|
||||
def run(self, terms):
|
||||
return [ f for f in glob.glob(terms) if os.path.isfile(f) ]
|
||||
|
||||
|
||||
|
|
@ -398,7 +398,10 @@ def template_from_file(basedir, path, vars):
|
|||
environment.filters['from_json'] = json.loads
|
||||
environment.filters['to_yaml'] = yaml.dump
|
||||
environment.filters['from_yaml'] = yaml.load
|
||||
data = codecs.open(realpath, encoding="utf8").read()
|
||||
try:
|
||||
data = codecs.open(realpath, encoding="utf8").read()
|
||||
except:
|
||||
raise errors.AnsibleError("unable to process as utf-8: %s" % realpath)
|
||||
t = environment.from_string(data)
|
||||
vars = vars.copy()
|
||||
try:
|
||||
|
@ -668,7 +671,8 @@ def filter_leading_non_json_lines(buf):
|
|||
|
||||
def import_plugins(directory):
|
||||
modules = {}
|
||||
for path in glob.glob(os.path.join(directory, '*.py')):
|
||||
python_files = os.path.join(directory, '*.py')
|
||||
for path in glob.glob(python_files):
|
||||
if path.startswith("_"):
|
||||
continue
|
||||
name, ext = os.path.splitext(os.path.basename(path))
|
||||
|
|
|
@ -89,9 +89,9 @@ def main():
|
|||
module.fail_json(msg="Destination %s not writable" % (dest))
|
||||
if not os.access(dest, os.R_OK):
|
||||
module.fail_json(msg="Destination %s not readable" % (dest))
|
||||
# Allow dest to be directory without compromising md5 check
|
||||
if (os.path.isdir(dest)):
|
||||
module.fail_json(msg="Destination %s cannot be a directory" % (dest))
|
||||
basename = os.path.basename(src)
|
||||
dest = os.path.join(dest, basename)
|
||||
md5sum_dest = module.md5(dest)
|
||||
else:
|
||||
if not os.path.exists(os.path.dirname(dest)):
|
||||
|
|
12
library/file
12
library/file
|
@ -339,11 +339,18 @@ def main():
|
|||
params = module.params
|
||||
state = params['state']
|
||||
path = os.path.expanduser(params['path'])
|
||||
|
||||
# source is both the source of a symlink or an informational passing of the src for a template module
|
||||
# or copy module, even if this module never uses it, it is needed to key off some things
|
||||
|
||||
src = params.get('src', None)
|
||||
if src:
|
||||
src = os.path.expanduser(src)
|
||||
force = module.boolean(params['force'])
|
||||
|
||||
if src is not None and os.path.isdir(path):
|
||||
params['path'] = path = os.path.join(path, os.path.basename(src))
|
||||
|
||||
mode = params.get('mode', None)
|
||||
owner = params.get('owner', None)
|
||||
group = params.get('group', None)
|
||||
|
@ -370,6 +377,7 @@ def main():
|
|||
changed = False
|
||||
|
||||
prev_state = 'absent'
|
||||
|
||||
if os.path.lexists(path):
|
||||
if os.path.islink(path):
|
||||
prev_state = 'link'
|
||||
|
@ -392,7 +400,7 @@ def main():
|
|||
module_exit_json(path=path, changed=True)
|
||||
|
||||
if prev_state != 'absent' and prev_state != state and not force:
|
||||
module_fail_json(path=path, msg='refusing to convert between %s and %s' % (prev_state, state))
|
||||
module_fail_json(path=path, msg='refusing to convert between %s and %s for %s' % (prev_state, state, src))
|
||||
|
||||
if prev_state == 'absent' and state == 'absent':
|
||||
module_exit_json(path=path, changed=False)
|
||||
|
@ -400,7 +408,7 @@ def main():
|
|||
if state == 'file':
|
||||
|
||||
if prev_state != 'file':
|
||||
module_fail_json(path=path, msg='file does not exist, use copy or template module to create')
|
||||
module_fail_json(path=path, msg='file (%s) does not exist, use copy or template module to create' % path)
|
||||
|
||||
# set modes owners and context as needed
|
||||
changed = set_context_if_different(path, secontext, changed)
|
||||
|
|
|
@ -199,7 +199,6 @@ def serve(module, password, port, minutes):
|
|||
# password as a variable in ansible is never logged though, so it serves well
|
||||
|
||||
key = AesKey.Read(password)
|
||||
log("DEBUG KEY=%s" % key) # REALLY NEED TO REMOVE THIS, DEBUG/DEV ONLY!
|
||||
|
||||
while True:
|
||||
|
||||
|
|
|
@ -193,8 +193,11 @@ class TestRunner(unittest.TestCase):
|
|||
assert self._run('file', ['dest=' + filedemo, 'state=file'])['failed']
|
||||
assert os.path.isdir(filedemo)
|
||||
|
||||
assert self._run('file', ['dest=' + filedemo, 'src=/dev/null', 'state=link'])['failed']
|
||||
assert os.path.isdir(filedemo)
|
||||
# this used to fail but will now make a 'null' symlink in the directory pointing to dev/null.
|
||||
# I feel this is ok but don't want to enforce it with a test.
|
||||
#result = self._run('file', ['dest=' + filedemo, 'src=/dev/null', 'state=link'])
|
||||
#assert result['failed']
|
||||
#assert os.path.isdir(filedemo)
|
||||
|
||||
assert self._run('file', ['dest=' + filedemo, 'mode=701', 'state=directory'])['changed']
|
||||
assert os.path.isdir(filedemo) and os.stat(filedemo).st_mode == 040701
|
||||
|
@ -245,7 +248,9 @@ class TestRunner(unittest.TestCase):
|
|||
assert self._run('file', ['dest=' + filedemo, 'state=file', 'force=yes'])['failed']
|
||||
assert os.path.isdir(filedemo)
|
||||
|
||||
assert self._run('file', ['dest=' + filedemo, 'src=/dev/null', 'state=link', 'force=yes'])['changed']
|
||||
result = self._run('file', ['dest=' + filedemo, 'src=/dev/null', 'state=link', 'force=yes'])
|
||||
assert result['changed']
|
||||
print result
|
||||
assert os.path.islink(filedemo)
|
||||
os.unlink(filedemo)
|
||||
|
||||
|
|
Loading…
Reference in a new issue