diff --git a/changelogs/fragments/ansible-test-auto-collection-loading.yml b/changelogs/fragments/ansible-test-auto-collection-loading.yml new file mode 100644 index 00000000000..71e2f8b285c --- /dev/null +++ b/changelogs/fragments/ansible-test-auto-collection-loading.yml @@ -0,0 +1,2 @@ +minor_changes: + - ansible-test - Support automatic loading of test collections in core integration tests. diff --git a/test/integration/targets/ansible-test-collection-indirect-by-meta/aliases b/test/integration/targets/ansible-test-collection-indirect-by-meta/aliases new file mode 100644 index 00000000000..136c05e0d02 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-indirect-by-meta/aliases @@ -0,0 +1 @@ +hidden diff --git a/test/integration/targets/ansible-test-collection-indirect-by-meta/ansible_collections/ansible_test/ansible_test_collection_indirect_by_meta/plugins/modules/hello.py b/test/integration/targets/ansible-test-collection-indirect-by-meta/ansible_collections/ansible_test/ansible_test_collection_indirect_by_meta/plugins/modules/hello.py new file mode 100644 index 00000000000..76fc168528a --- /dev/null +++ b/test/integration/targets/ansible-test-collection-indirect-by-meta/ansible_collections/ansible_test/ansible_test_collection_indirect_by_meta/plugins/modules/hello.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +from ansible.module_utils.basic import AnsibleModule + + +def main(): + module = AnsibleModule(argument_spec=dict()) + module.exit_json(source='meta') + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/ansible-test-collection-indirect-by-meta/tasks/main.yml b/test/integration/targets/ansible-test-collection-indirect-by-meta/tasks/main.yml new file mode 100644 index 00000000000..3dc4bb826b4 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-indirect-by-meta/tasks/main.yml @@ -0,0 +1,3 @@ +- name: Set a fact indicating this collection was loaded as a dependency + set_fact: + by_meta_loaded: true diff --git a/test/integration/targets/ansible-test-collection-indirect-by-needs-target/aliases b/test/integration/targets/ansible-test-collection-indirect-by-needs-target/aliases new file mode 100644 index 00000000000..136c05e0d02 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-indirect-by-needs-target/aliases @@ -0,0 +1 @@ +hidden diff --git a/test/integration/targets/ansible-test-collection-indirect-by-needs-target/ansible_collections/ansible_test/ansible_test_collection_indirect_by_needs_target/plugins/modules/hello.py b/test/integration/targets/ansible-test-collection-indirect-by-needs-target/ansible_collections/ansible_test/ansible_test_collection_indirect_by_needs_target/plugins/modules/hello.py new file mode 100644 index 00000000000..25487f364de --- /dev/null +++ b/test/integration/targets/ansible-test-collection-indirect-by-needs-target/ansible_collections/ansible_test/ansible_test_collection_indirect_by_needs_target/plugins/modules/hello.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +from ansible.module_utils.basic import AnsibleModule + + +def main(): + module = AnsibleModule(argument_spec=dict()) + module.exit_json(source='needs/target') + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/ansible-test-collection-role-dependencies/aliases b/test/integration/targets/ansible-test-collection-role-dependencies/aliases new file mode 100644 index 00000000000..3dab52c25f0 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-role-dependencies/aliases @@ -0,0 +1,5 @@ +shippable/posix/group3 # runs in the distro test containers +shippable/generic/group1 # runs in the default test container +needs/target/ansible-test-collection-indirect-by-needs-target +context/controller +gather_facts/no diff --git a/test/integration/targets/ansible-test-collection-role-dependencies/meta/main.yml b/test/integration/targets/ansible-test-collection-role-dependencies/meta/main.yml new file mode 100644 index 00000000000..45c712166a1 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-role-dependencies/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - ansible-test-collection-indirect-by-meta diff --git a/test/integration/targets/ansible-test-collection-role-dependencies/tasks/main.yml b/test/integration/targets/ansible-test-collection-role-dependencies/tasks/main.yml new file mode 100644 index 00000000000..f393bd67165 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-role-dependencies/tasks/main.yml @@ -0,0 +1,18 @@ +- name: Verify collection dependencies executed as appropriate + assert: + that: + - by_meta_loaded + +- name: Use dependency defined by alias + ansible_test.ansible_test_collection_indirect_by_needs_target.hello: + register: by_needs_target + +- name: Use dependency defined by meta + ansible_test.ansible_test_collection_indirect_by_meta.hello: + register: by_meta + +- name: Verify the results + assert: + that: + - by_needs_target.source == 'needs/target' + - by_meta.source == 'meta' diff --git a/test/integration/targets/ansible-test-collection-script-dependencies/aliases b/test/integration/targets/ansible-test-collection-script-dependencies/aliases new file mode 100644 index 00000000000..26392823348 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-script-dependencies/aliases @@ -0,0 +1,4 @@ +shippable/posix/group3 # runs in the distro test containers +shippable/generic/group1 # runs in the default test container +needs/target/ansible-test-collection-indirect-by-needs-target +context/controller diff --git a/test/integration/targets/ansible-test-collection-script-dependencies/runme.sh b/test/integration/targets/ansible-test-collection-script-dependencies/runme.sh new file mode 100755 index 00000000000..7db17624f17 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-script-dependencies/runme.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -eux + +ansible-playbook runme.yml "${@}" -i ../../inventory diff --git a/test/integration/targets/ansible-test-collection-script-dependencies/runme.yml b/test/integration/targets/ansible-test-collection-script-dependencies/runme.yml new file mode 100644 index 00000000000..b61faed8638 --- /dev/null +++ b/test/integration/targets/ansible-test-collection-script-dependencies/runme.yml @@ -0,0 +1,11 @@ +- hosts: testhost + gather_facts: no + tasks: + - name: Use dependency defined by alias + ansible_test.ansible_test_collection_indirect_by_needs_target.hello: + register: by_needs_target + + - name: Verify the results + assert: + that: + - by_needs_target.source == 'needs/target' diff --git a/test/integration/targets/collection/setup.sh b/test/integration/targets/collection/setup.sh index 346a21ca9a2..f562812eb57 100755 --- a/test/integration/targets/collection/setup.sh +++ b/test/integration/targets/collection/setup.sh @@ -15,6 +15,9 @@ # # 4) Windows tests need access to the ansible.windows vendored collection. # This script copies any of the existing collections in ANSIBLE_COLLECTIONS_PATH to the temporary directory. +# +# NOTE: This script is *NOT* compatible ansible-test's built-in collection injection feature, +# since it assumes that ANSIBLE_COLLECTIONS_PATH contains only a single path. set -eu -o pipefail diff --git a/test/lib/ansible_test/_internal/commands/integration/__init__.py b/test/lib/ansible_test/_internal/commands/integration/__init__.py index 421c3d4a3d4..8881877ea91 100644 --- a/test/lib/ansible_test/_internal/commands/integration/__init__.py +++ b/test/lib/ansible_test/_internal/commands/integration/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import collections.abc as c import contextlib +import dataclasses import datetime import json import os @@ -175,6 +176,51 @@ def get_files_needed(target_dependencies: list[IntegrationTarget]) -> list[str]: return files_needed +def get_collection_roots_needed(target_dependencies: list[IntegrationTarget]) -> list[str]: + """ + Return a list of collection roots needed by the given list of target dependencies. + This feature is only supported for ansible-core, not collections. + To be recognized, a collection must reside in the following directory: + test/integration/targets/{target_name}/ansible_collections/ansible_test/{target_name} + If the target name has dashes, they will be replaced with underscores for the collection name. + It is an error if the collection root contains additional namespaces or collections. + This is enforced to ensure there are no naming conflicts between collection roots. + """ + if not data_context().content.is_ansible: + return [] + + collection_roots: list[str] = [] + namespace_name = 'ansible_test' + + for target_dependency in target_dependencies: + collection_root = os.path.join(data_context().content.integration_targets_path, target_dependency.name) + collection_name = target_dependency.name.replace('-', '_') + namespaces_path = os.path.join(collection_root, 'ansible_collections') + namespace_path = os.path.join(namespaces_path, namespace_name) + collection_path = os.path.join(namespace_path, collection_name) + + if not os.path.isdir(collection_path): + continue + + namespaces = set(os.listdir(namespaces_path)) + namespaces.remove(namespace_name) + + if namespaces: + raise ApplicationError(f"Additional test collection namespaces not supported: {', '.join(sorted(namespaces))}") + + collections = set(os.listdir(namespace_path)) + collections.remove(collection_name) + + if collections: + raise ApplicationError(f"Additional test collections not supported: {', '.join(sorted(collections))}") + + collection_roots.append(collection_root) + + collection_roots = sorted(set(collection_roots)) + + return collection_roots + + def check_inventory(args: IntegrationConfig, inventory_path: str) -> None: """Check the given inventory for issues.""" if not isinstance(args.controller, OriginConfig): @@ -289,6 +335,7 @@ def integration_test_environment( target_dependencies = sorted([target] + list(cache.dependency_map.get(target.name, set()))) files_needed = get_files_needed(target_dependencies) + collection_roots = get_collection_roots_needed(target_dependencies) integration_dir = os.path.join(temp_dir, data_context().content.integration_path) targets_dir = os.path.join(temp_dir, data_context().content.integration_targets_path) @@ -336,7 +383,7 @@ def integration_test_environment( make_dirs(os.path.dirname(file_dst)) shutil.copy2(file_src, file_dst) - yield IntegrationEnvironment(temp_dir, integration_dir, targets_dir, inventory_path, ansible_config, vars_file) + yield IntegrationEnvironment(temp_dir, integration_dir, targets_dir, inventory_path, ansible_config, vars_file, collection_roots) finally: if not args.explain: remove_tree(temp_dir) @@ -628,6 +675,7 @@ def command_integration_script( if config_path: cmd += ['-e', '@%s' % config_path] + test_env.update_environment(env) env.update(coverage_manager.get_environment(target.name, target.aliases)) cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False) @@ -747,6 +795,7 @@ def command_integration_role( env['ANSIBLE_ROLES_PATH'] = test_env.targets_dir + test_env.update_environment(env) env.update(coverage_manager.get_environment(target.name, target.aliases)) cover_python(args, host_state.controller_profile.python, cmd, target.name, env, cwd=cwd, capture=False) @@ -826,16 +875,25 @@ def integration_environment( return env +@dataclasses.dataclass(frozen=True) class IntegrationEnvironment: """Details about the integration environment.""" - def __init__(self, test_dir: str, integration_dir: str, targets_dir: str, inventory_path: str, ansible_config: str, vars_file: str) -> None: - self.test_dir = test_dir - self.integration_dir = integration_dir - self.targets_dir = targets_dir - self.inventory_path = inventory_path - self.ansible_config = ansible_config - self.vars_file = vars_file + test_dir: str + integration_dir: str + targets_dir: str + inventory_path: str + ansible_config: str + vars_file: str + collection_roots: list[str] = dataclasses.field(default_factory=list) + + def update_environment(self, env: dict[str, str]) -> None: + """Update the given environment dictionary with the variables necessary for this integration environment.""" + collections_path = value.split(':') if (value := env.get('ANSIBLE_COLLECTIONS_PATH')) else [] + collections_path.extend([os.path.join(self.test_dir, root) for root in self.collection_roots]) + + if collections_path: + env['ANSIBLE_COLLECTIONS_PATH'] = ':'.join(collections_path) class IntegrationCache(CommonCache):