Collection content loading (#52194)

* basic plugin loading working (with many hacks)

* task collections working

* play/block-level collection module/action working

* implement PEP302 loader

* implicit package support (no need for __init.py__ in collections)
* provides future options for secure loading of content that shouldn't execute inside controller (eg, actively ignore __init__.py on content/module paths)
* provide hook for synthetic collection setup (eg ansible.core pseudo-collection for specifying built-in plugins without legacy path, etc)

* synthetic package support

* ansible.core.plugins mapping works, others don't

* synthetic collections working for modules/actions

* fix direct-load legacy

* change base package name to ansible_collections

* note

* collection role loading

* expand paths from installed content root vars

* feature complete?

* rename ansible.core to ansible.builtin

* and various sanity fixes

* sanity tweaks

* unittest fixes

* less grabby error handler on has_plugin

* probably need to replace with a or harden callers

* fix win_ping test

* disable module test with explicit file extension; might be able to support in some scenarios, but can't see any other tests that verify that behavior...

* fix unicode conversion issues on py2

* attempt to keep things working-ish on py2.6

* python2.6 test fun round 2

* rename dirs/configs to "collections"

* add wrapper dir for content-adjacent

* fix pythoncheck to use localhost

* unicode tweaks, native/bytes string prefixing

* rename COLLECTION_PATHS to COLLECTIONS_PATHS

* switch to pathspec

* path handling cleanup

* change expensive `all` back to or chain

* unused import cleanup

* quotes tweak

* use wrapped iter/len in Jinja proxy

* var name expansion

* comment seemingly overcomplicated playbook_paths resolution

* drop unnecessary conditional nesting

* eliminate extraneous local

* zap superfluous validation function

* use slice for rolespec NS assembly

* misc naming/unicode fixes

* collection callback loader asks if valid FQ name instead of just '.'
* switch collection role resolution behavior to be internally `text` as much as possible

* misc fixmes

* to_native in exception constructor
* (slightly) detangle tuple accumulation mess in module_utils __init__ walker

* more misc fixmes

* tighten up action dispatch, add unqualified action test

* rename Collection mixin to CollectionSearch

* (attempt to) avoid potential confusion/conflict with builtin collections, etc

* stale fixmes

* tighten up pluginloader collections determination

* sanity test fixes

* ditch regex escape

* clarify comment

* update default collections paths config entry

* use PATH format instead of list

* skip integration tests on Python 2.6

ci_complete
This commit is contained in:
Matt Davis
2019-03-28 10:41:39 -07:00
committed by GitHub
parent 5173548a9f
commit f86345f777
56 changed files with 1512 additions and 109 deletions

View File

@@ -0,0 +1,3 @@
# use a plugin defined in a content-adjacent collection to ensure we added it properly
plugin: testns.content_adj.statichost
hostname: dynamic_host_a

View File

@@ -0,0 +1,2 @@
shippable/posix/group4
skip/python2.6

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, source='sys')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, failed=True, msg='this collection should be masked by testcoll in the user content root')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, source='sys')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,2 @@
- fail:
msg: this role should never be visible or runnable

View File

@@ -0,0 +1,30 @@
from ansible.plugins.action import ActionBase
from ansible.plugins import loader
class ActionModule(ActionBase):
TRANSFERS_FILES = False
_VALID_ARGS = frozenset(('type', 'name'))
def run(self, tmp=None, task_vars=None):
if task_vars is None:
task_vars = dict()
result = super(ActionModule, self).run(None, task_vars)
type = self._task.args.get('type')
name = self._task.args.get('name')
result = dict(changed=False, collection_list=self._task.collections)
if all([type, name]):
attr_name = '{0}_loader'.format(type)
typed_loader = getattr(loader, attr_name, None)
if not typed_loader:
return (dict(failed=True, msg='invalid plugin type {0}'.format(type)))
result['plugin_path'] = typed_loader.find_plugin(name, collection_list=self._task.collections)
return result

View File

@@ -0,0 +1,24 @@
from ansible.plugins.callback import CallbackBase
DOCUMENTATION = '''
callback: usercallback
callback_type: notification
short_description: does stuff
description:
- does some stuff
'''
class CallbackModule(CallbackBase):
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = 'aggregate'
CALLBACK_NAME = 'usercallback'
CALLBACK_NEEDS_WHITELIST = True
def __init__(self):
super(CallbackModule, self).__init__()
self._display.display("loaded usercallback from collection, yay")
def v2_runner_on_ok(self, result):
self._display.display("usercallback says ok")

View File

@@ -0,0 +1,38 @@
from ansible.module_utils._text import to_native
from ansible.plugins.connection import ConnectionBase
DOCUMENTATION = """
connection: localconn
short_description: do stuff local
description:
- does stuff
options:
connectionvar:
description:
- something we set
default: the_default
vars:
- name: ansible_localconn_connectionvar
"""
class Connection(ConnectionBase):
transport = 'local'
has_pipelining = True
def _connect(self):
return self
def exec_command(self, cmd, in_data=None, sudoable=True):
stdout = 'localconn ran {0}'.format(to_native(cmd))
stderr = 'connectionvar is {0}'.format(to_native(self.get_option('connectionvar')))
return (0, stdout, stderr)
def put_file(self, in_path, out_path):
raise NotImplementedError('just a test')
def fetch_file(self, in_path, out_path):
raise NotImplementedError('just a test')
def close(self):
self._connected = False

View File

@@ -0,0 +1,10 @@
def testfilter(data):
return "{0}_from_userdir".format(data)
class FilterModule(object):
def filters(self):
return {
'testfilter': testfilter
}

View File

@@ -0,0 +1,8 @@
from ansible.plugins.lookup import LookupBase
class LookupModule(LookupBase):
def run(self, terms, variables, **kwargs):
return ['lookup_from_user_dir']

View File

@@ -0,0 +1,7 @@
# FIXME: this style (full module import via from) doesn't work yet from collections
# from ansible_collections.testns.testcoll.plugins.module_utils import secondary
import ansible_collections.testns.testcoll.plugins.module_utils.secondary
def thingtocall():
return "thingtocall in base called " + ansible_collections.testns.testcoll.plugins.module_utils.secondary.thingtocall()

View File

@@ -0,0 +1,2 @@
def thingtocall():
return "thingtocall in leaf"

View File

@@ -0,0 +1,2 @@
def thingtocall():
return "thingtocall in secondary"

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, source='user')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, source='user')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
import json
import sys
# FIXME: this is only required due to a bug around "new style module detection"
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.testns.testcoll.plugins.module_utils.base import thingtocall
def main():
mu_result = thingtocall()
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
sys.exit()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
import json
import sys
# FIXME: this is only required due to a bug around "new style module detection"
from ansible.module_utils.basic import AnsibleModule
import ansible_collections.testns.testcoll.plugins.module_utils.leaf
def main():
mu_result = ansible_collections.testns.testcoll.plugins.module_utils.leaf.thingtocall()
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
sys.exit()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
import json
import sys
# FIXME: this is only required due to a bug around "new style module detection"
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.testns.testcoll.plugins.module_utils.leaf import thingtocall
def main():
mu_result = thingtocall()
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
sys.exit()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
import json
import sys
# FIXME: this is only required due to a bug around "new style module detection"
from ansible.module_utils.basic import AnsibleModule
# FIXME: this style doesn't work yet under collections
from ansible_collections.testns.testcoll.plugins.module_utils import leaf
def main():
mu_result = leaf.thingtocall()
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
sys.exit()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,9 @@
def testtest(data):
return data == 'from_user'
class TestModule(object):
def tests(self):
return {
'testtest': testtest
}

View File

@@ -0,0 +1,4 @@
collections:
- ansible.builtin
- testns.coll_in_sys
- bogus.fromrolemeta

View File

@@ -0,0 +1,30 @@
- name: check collections list from role meta
plugin_lookup:
register: pluginlookup_out
- name: call role-local ping module
ping:
register: ping_out
- name: call unqualified module in another collection listed in role meta (testns.coll_in_sys)
systestmodule:
register: systestmodule_out
# verify that pluginloader caching doesn't prevent us from explicitly calling a builtin plugin with the same name
- name: call builtin ping module explicitly
ansible.builtin.ping:
register: builtinping_out
- debug:
msg: '{{ test_role_input | default("(undefined)") }}'
register: test_role_output
# FIXME: add tests to ensure that block/task level stuff in a collection-hosted role properly inherit role default/meta values
- assert:
that:
- pluginlookup_out.collection_list == ['testns.testcoll', 'ansible.builtin', 'testns.coll_in_sys', 'bogus.fromrolemeta']
- ping_out.source is defined and ping_out.source == 'user'
- systestmodule_out.source is defined and systestmodule_out.source == 'sys'
- builtinping_out.ping is defined and builtinping_out.ping == 'pong'
- test_role_input is not defined or test_role_input == test_role_output.msg

View File

@@ -0,0 +1,55 @@
# Copyright (c) 2018 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = '''
inventory: statichost
short_description: Add a single host
options:
plugin:
description: plugin name (must be statichost)
required: true
hostname:
description: Toggle display of stderr even when script was successful
type: list
'''
from ansible.errors import AnsibleParserError
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable
class InventoryModule(BaseInventoryPlugin):
NAME = 'statichost'
def __init__(self):
super(InventoryModule, self).__init__()
self._hosts = set()
def verify_file(self, path):
''' Verify if file is usable by this plugin, base does minimal accessibility check '''
if not path.endswith('.statichost.yml') and not path.endswith('.statichost.yaml'):
return False
return super(InventoryModule, self).verify_file(path)
def parse(self, inventory, loader, path, cache=None):
super(InventoryModule, self).parse(inventory, loader, path)
config_data = loader.load_from_file(path, cache=False)
host_to_add = config_data.get('hostname')
if not host_to_add:
raise AnsibleParserError("hostname was not specified")
# this is where the magic happens
self.inventory.add_host(host_to_add, 'all')
# self.inventory.add_group()...
# self.inventory.add_child()...
# self.inventory.set_variable()..

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
import json
def main():
print(json.dumps(dict(changed=False, source='content_adj')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
#!/usr/bin/python
import json
def main():
print(json.dumps(dict(changed=False, source='legacy_library_dir')))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,280 @@
- hosts: testhost
tasks:
# basic test of FQ module lookup and that we got the right one (user-dir hosted)
- name: exec FQ module in a user-dir testns collection
testns.testcoll.testmodule:
register: testmodule_out
# verifies that distributed collection subpackages are visible under a multi-location namespace (testns exists in user and sys locations)
- name: exec FQ module in a sys-dir testns collection
testns.coll_in_sys.systestmodule:
register: systestmodule_out
# verifies that content-adjacent collections were automatically added to the installed content roots
- name: exec FQ module from content-adjacent collection
testns.content_adj.contentadjmodule:
register: contentadjmodule_out
# content should only be loaded from the first visible instance of a collection
- name: attempt to look up FQ module in a masked collection
testns.testcoll.plugin_lookup:
type: module
name: testns.testcoll.maskedmodule
register: maskedmodule_out
# module with a granular module_utils import (from (this collection).module_utils.leaf import thingtocall)
- name: exec module with granular module utils import from this collection
testns.testcoll.uses_leaf_mu_granular_import:
register: granular_out
# module with a granular nested module_utils import (from (this collection).module_utils.base import thingtocall,
# where base imports secondary from the same collection's module_utils)
- name: exec module with nested module utils from this collection
testns.testcoll.uses_base_mu_granular_nested_import:
register: granular_nested_out
# module with a flat module_utils import (import (this collection).module_utils.leaf)
- name: exec module with flat module_utils import from this collection
testns.testcoll.uses_leaf_mu_flat_import:
register: flat_out
# FIXME: this one doesn't work yet
# module with a full-module module_utils import using 'from' (from (this collection).module_utils import leaf)
- name: exec module with full-module module_utils import using 'from' from this collection
testns.testcoll.uses_leaf_mu_module_import_from:
ignore_errors: true
register: from_out
- assert:
that:
- testmodule_out.source == 'user'
- systestmodule_out.source == 'sys'
- contentadjmodule_out.source == 'content_adj'
- not maskedmodule_out.plugin_path
- granular_out.mu_result == 'thingtocall in leaf'
- granular_nested_out.mu_result == 'thingtocall in base called thingtocall in secondary'
- flat_out.mu_result == 'thingtocall in leaf'
- from_out is failed # FIXME: switch back once this import is fixed --> from_out.mu_result == 'thingtocall in leaf'
- name: exercise filters/tests/lookups
assert:
that:
- "'data' | testns.testcoll.testfilter == 'data_from_userdir'"
- "'from_user' is testns.testcoll.testtest"
- lookup('testns.testcoll.mylookup') == 'lookup_from_user_dir'
# ensure that the synthetic ansible.builtin collection limits to builtin plugins, that ansible.legacy loads overrides
# from legacy plugin dirs, and that a same-named plugin loaded from a real collection is not masked by the others
- hosts: testhost
tasks:
- name: test unqualified ping from library dir
ping:
register: unqualified_ping_out
- name: test legacy-qualified ping from library dir
ansible.legacy.ping:
register: legacy_ping_out
- name: test builtin ping
ansible.builtin.ping:
register: builtin_ping_out
- name: test collection-based ping
testns.testcoll.ping:
register: collection_ping_out
- assert:
that:
- unqualified_ping_out.source == 'legacy_library_dir'
- legacy_ping_out.source == 'legacy_library_dir'
- builtin_ping_out.ping == 'pong'
- collection_ping_out.source == 'user'
# verify the default value for the collections list is empty
- hosts: testhost
tasks:
- name: sample default collections value
testns.testcoll.plugin_lookup:
register: coll_default_out
- assert:
that:
# in original release, collections defaults to empty, which is mostly equivalent to ansible.legacy
- not coll_default_out.collection_list
# ensure that inheritance/masking works as expected, that the proper default values are injected when missing,
# and that the order is preserved if one of the magic values is explicitly specified
- name: verify collections keyword play/block/task inheritance and magic values
hosts: testhost
collections:
- bogus.fromplay
tasks:
- name: sample play collections value
testns.testcoll.plugin_lookup:
register: coll_play_out
- name: collections override block-level
collections:
- bogus.fromblock
block:
- name: sample block collections value
testns.testcoll.plugin_lookup:
register: coll_block_out
- name: sample task collections value
collections:
- bogus.fromtask
testns.testcoll.plugin_lookup:
register: coll_task_out
- name: sample task with explicit core
collections:
- ansible.builtin
- bogus.fromtaskexplicitcore
testns.testcoll.plugin_lookup:
register: coll_task_core
- name: sample task with explicit legacy
collections:
- ansible.legacy
- bogus.fromtaskexplicitlegacy
testns.testcoll.plugin_lookup:
register: coll_task_legacy
- assert:
that:
# ensure that parent value inheritance is masked properly by explicit setting
- coll_play_out.collection_list == ['bogus.fromplay', 'ansible.legacy']
- coll_block_out.collection_list == ['bogus.fromblock', 'ansible.legacy']
- coll_task_out.collection_list == ['bogus.fromtask', 'ansible.legacy']
- coll_task_core.collection_list == ['ansible.builtin', 'bogus.fromtaskexplicitcore']
- coll_task_legacy.collection_list == ['ansible.legacy', 'bogus.fromtaskexplicitlegacy']
- name: verify unqualified plugin resolution behavior
hosts: testhost
collections:
- testns.testcoll
- testns.coll_in_sys
- testns.contentadj
tasks:
# basic test of unqualified module lookup and that we got the right one (user-dir hosted, there's another copy of
# this one in the same-named collection in sys dir that should be masked
- name: exec unqualified module in a user-dir testns collection
testmodule:
register: testmodule_out
# use another collection to verify that we're looking in all collections listed on the play
- name: exec unqualified module in a sys-dir testns collection
systestmodule:
register: systestmodule_out
# ensure we're looking up actions properly
- name: unqualified action test
plugin_lookup:
register: pluginlookup_out
- assert:
that:
- testmodule_out.source == 'user'
- systestmodule_out.source == 'sys'
- pluginlookup_out.collection_list == ['testns.testcoll', 'testns.coll_in_sys', 'testns.contentadj', 'ansible.legacy']
# FIXME: this won't work until collections list gets passed through task templar
# - name: exercise unqualified filters/tests/lookups
# assert:
# that:
# - "'data' | testfilter == 'data_from_userdir'"
# - "'from_user' is testtest"
# - lookup('mylookup') == 'lookup_from_user_dir'
# test keyword-static execution of a FQ collection-backed role
- name: verify collection-backed role execution (keyword static)
hosts: testhost
collections:
# set to ansible.builtin only to ensure that roles function properly without inheriting the play's collections config
- ansible.builtin
vars:
test_role_input: keyword static
roles:
- role: testns.testcoll.testrole
tasks:
- name: ensure role executed
assert:
that:
- test_role_output.msg == test_role_input
# test dynamic execution of a FQ collection-backed role
- name: verify collection-backed role execution (dynamic)
hosts: testhost
collections:
# set to ansible.builtin only to ensure that roles function properly without inheriting the play's collections config
- ansible.builtin
vars:
test_role_input: dynamic
tasks:
- include_role:
name: testns.testcoll.testrole
- name: ensure role executed
assert:
that:
- test_role_output.msg == test_role_input
# test task-static execution of a FQ collection-backed role
- name: verify collection-backed role execution (task static)
hosts: testhost
collections:
- ansible.builtin
vars:
test_role_input: task static
tasks:
- import_role:
name: testns.testcoll.testrole
- name: ensure role executed
assert:
that:
- test_role_output.msg == test_role_input
# test a legacy playbook-adjacent role, ensure that play collections config is not inherited
- name: verify legacy playbook-adjacent role behavior
hosts: testhost
collections:
- bogus.bogus
vars:
test_role_input: legacy playbook-adjacent
roles:
- testrole
# FIXME: this should technically work to look up a playbook-adjacent role
# - ansible.legacy.testrole
tasks:
- name: ensure role executed
assert:
that:
- test_role_output.msg == test_role_input
- name: test a collection-hosted connection plugin against a host from a collection-hosted inventory plugin
hosts: dynamic_host_a
vars:
ansible_connection: testns.testcoll.localconn
ansible_localconn_connectionvar: from_play
tasks:
- raw: echo 'hello world'
register: connection_out
- assert:
that:
- connection_out.stdout == "localconn ran echo 'hello world'"
# ensure that the connection var we overrode above made it into the running config
- connection_out.stderr == "connectionvar is from_play"
- hosts: testhost
tasks:
- assert:
that:
- hostvars['dynamic_host_a'] is defined
- hostvars['dynamic_host_a'].connection_out.stdout == "localconn ran echo 'hello world'"

View File

@@ -0,0 +1,8 @@
# this test specifically avoids testhost because we need to know about the controller's Python
- hosts: localhost
gather_facts: yes
gather_subset: min
tasks:
- debug:
msg: UNSUPPORTEDPYTHON {{ ansible_python_version }}
when: ansible_python_version is version('2.7', '<')

View File

@@ -0,0 +1,25 @@
- debug:
msg: executing testrole from legacy playbook-adjacent roles dir
- name: exec a FQ module from a legacy role
testns.testcoll.testmodule:
register: coll_module_out
- name: exec a legacy playbook-adjacent module from a legacy role
ping:
register: ping_out
- name: sample collections list inside a legacy role (should be empty)
testns.testcoll.plugin_lookup:
register: plugin_lookup_out
- debug:
msg: '{{ test_role_input | default("(undefined)") }}'
register: test_role_output
- assert:
that:
- coll_module_out.source == 'user'
# ensure we used the library/ ping override, not the builtin or one from another collection
- ping_out.source == 'legacy_library_dir'
- not plugin_lookup_out.collection_list

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -eux
export ANSIBLE_COLLECTIONS_PATHS=$PWD/collection_root_user:$PWD/collection_root_sys
export ANSIBLE_GATHERING=explicit
export ANSIBLE_GATHER_SUBSET=minimal
# temporary hack to keep this test from running on Python 2.6 in CI
if ansible-playbook -i ../../inventory pythoncheck.yml | grep UNSUPPORTEDPYTHON; then
echo skipping test for unsupported Python version...
exit 0
fi
# test callback
ANSIBLE_CALLBACK_WHITELIST=testns.testcoll.usercallback ansible localhost -m ping | grep "usercallback says ok"
# run test playbook
ansible-playbook -i ../../inventory -i ./a.statichost.yml -v play.yml

View File

@@ -40,7 +40,8 @@
- win_ping_with_data_result.ping == '☠'
- name: test win_ping.ps1 with data as complex args
win_ping.ps1:
# win_ping.ps1: # TODO: do we want to actually support this? no other tests that I can see...
win_ping:
data: bleep
register: win_ping_ps1_result