diff --git a/ansible_runner/__init__.py b/ansible_runner/__init__.py index 44ccf1f6c..40a4be7b2 100644 --- a/ansible_runner/__init__.py +++ b/ansible_runner/__init__.py @@ -3,6 +3,7 @@ from .interface import run, run_async, \ run_command, run_command_async, \ get_plugin_docs, get_plugin_docs_async, get_plugin_list, \ + get_role_list, get_role_argspec, \ get_inventory, \ get_ansible_config # noqa from .exceptions import AnsibleRunnerException, ConfigurationError, CallbackError # noqa diff --git a/ansible_runner/config/doc.py b/ansible_runner/config/doc.py index 6612baf3c..7fded7717 100644 --- a/ansible_runner/config/doc.py +++ b/ansible_runner/config/doc.py @@ -121,3 +121,32 @@ def prepare_plugin_list_command(self, list_files=None, response_format=None, plu self.command = [self._ansible_doc_exec_path] + self.cmdline_args self._handle_command_wrap(self.execution_mode, self.cmdline_args) + + def prepare_role_list_command(self, collection_name, playbook_dir): + """ + ansible-doc -t role -l -j + """ + self._prepare_env(runner_mode=self.runner_mode) + self.cmdline_args = ['-t', 'role', '-l', '-j'] + if playbook_dir: + self.cmdline_args.extend(['--playbook-dir', playbook_dir]) + if collection_name: + self.cmdline_args.append(collection_name) + + self.command = [self._ansible_doc_exec_path] + self.cmdline_args + self._handle_command_wrap(self.execution_mode, self.cmdline_args) + + def prepare_role_argspec_command(self, role_name, collection_name, playbook_dir): + """ + ansible-doc -t role -j . + """ + self._prepare_env(runner_mode=self.runner_mode) + self.cmdline_args = ['-t', 'role', '-j'] + if playbook_dir: + self.cmdline_args.extend(['--playbook-dir', playbook_dir]) + if collection_name: + role_name = ".".join([collection_name, role_name]) + self.cmdline_args.append(role_name) + + self.command = [self._ansible_doc_exec_path] + self.cmdline_args + self._handle_command_wrap(self.execution_mode, self.cmdline_args) diff --git a/ansible_runner/interface.py b/ansible_runner/interface.py index 4c8714a47..48e6ad2c7 100644 --- a/ansible_runner/interface.py +++ b/ansible_runner/interface.py @@ -34,7 +34,7 @@ from ansible_runner.utils import ( dump_artifacts, check_isolation_executable_installed, - santize_json_response, + sanitize_json_response, signal_handler, ) @@ -540,7 +540,7 @@ def get_plugin_docs(plugin_names, plugin_type=None, response_format=None, snippe response = r.stdout.read() error = r.stderr.read() if response and response_format == 'json': - response = json.loads(santize_json_response(response)) + response = json.loads(sanitize_json_response(response)) return response, error @@ -647,7 +647,7 @@ def get_plugin_list(list_files=None, response_format=None, plugin_type=None, pla :type check_job_event_data: bool :returns: Returns a tuple of response and error string. In case if ``runner_mode`` is set to ``pexpect`` the error value is empty as - ``pexpect`` uses same output descriptor for stdout and stderr. If the vaue of ``response_format`` is ``json`` + ``pexpect`` uses same output descriptor for stdout and stderr. If the value of ``response_format`` is ``json`` it returns a python dictionary object. ''' event_callback_handler = kwargs.pop('event_handler', None) @@ -669,7 +669,7 @@ def get_plugin_list(list_files=None, response_format=None, plugin_type=None, pla response = r.stdout.read() error = r.stderr.read() if response and response_format == 'json': - response = json.loads(santize_json_response(response)) + response = json.loads(sanitize_json_response(response)) return response, error @@ -792,7 +792,7 @@ def get_inventory(action, inventories, response_format=None, host=None, playbook response = r.stdout.read() error = r.stderr.read() if response and response_format == 'json': - response = json.loads(santize_json_response(response)) + response = json.loads(sanitize_json_response(response)) return response, error @@ -897,3 +897,157 @@ def get_ansible_config(action, config_file=None, only_changed=None, **kwargs): response = r.stdout.read() error = r.stderr.read() return response, error + + +def get_role_list(collection=None, playbook_dir=None, **kwargs): + ''' + Run an ``ansible-doc`` command to get list of installed collection roles. + + Only roles that have an argument specification defined are returned. + + .. note:: Version added: 2.2 + + :param str collection: A fully qualified collection name used to filter the results. + :param str playbook_dir: This parameter is used to set the relative path to handle playbook adjacent installed roles. + + :param str runner_mode: The applicable values are ``pexpect`` and ``subprocess``. Default is set to ``subprocess``. + :param str host_cwd: The host current working directory to be mounted within the container (if enabled) and will be + the work directory within container. + :param dict envvars: Environment variables to be used when running Ansible. Environment variables will also be + read from ``env/envvars`` in ``private_data_dir`` + :param dict passwords: A dictionary containing password prompt patterns and response values used when processing output from + Ansible. Passwords will also be read from ``env/passwords`` in ``private_data_dir``. + :param dict settings: A dictionary containing settings values for the ``ansible-runner`` runtime environment. These will also + be read from ``env/settings`` in ``private_data_dir``. + :param str ssh_key: The ssh private key passed to ``ssh-agent`` as part of the ansible-playbook run. + :param bool quiet: Disable all output + :param bool json_mode: Store event data in place of stdout on the console and in the stdout file + :param str artifact_dir: The path to the directory where artifacts should live, this defaults to 'artifacts' under the private data dir + :param str project_dir: The path to the playbook content, this defaults to 'project' within the private data dir + :param int rotate_artifacts: Keep at most n artifact directories, disable with a value of 0 which is the default + :param int timeout: The timeout value in seconds that will be passed to either ``pexpect`` of ``subprocess`` invocation + (based on ``runner_mode`` selected) while executing command. If the timeout is triggered, it will force cancel the execution. + :param bool process_isolation: Enable process isolation using a container engine, such as podman. + :param str process_isolation_executable: Process isolation executable or container engine used to isolate execution. (default: podman) + :param str container_image: Container image to use when running an Ansible task (default: quay.io/ansible/ansible-runner:devel) + :param list container_volume_mounts: List of bind mounts in the form ``host_dir:/container_dir:labels``. (default: None) + :param list container_options: List of container options to pass to execution engine. + :param str container_workdir: The working directory within the container. + :param str fact_cache: A string that will be used as the name for the subdirectory of the fact cache in artifacts directory. + This is only used for 'jsonfile' type fact caches. + :param str fact_cache_type: A string of the type of fact cache to use. Defaults to 'jsonfile'. + :param str private_data_dir: The directory containing all runner metadata needed to invoke the runner + module. Output artifacts will also be stored here for later consumption. + :param str ident: The run identifier for this invocation of Runner. Will be used to create and name + the artifact directory holding the results of the invocation. + :param function event_handler: An optional callback that will be invoked any time an event is received by Runner itself, return True to keep the event + :param function cancel_callback: An optional callback that can inform runner to cancel (returning True) or not (returning False) + :param function finished_callback: An optional callback that will be invoked at shutdown after process cleanup. + :param function status_handler: An optional callback that will be invoked any time the status changes + (for example: started, running, failed, successful, timeout) + :param function artifacts_handler: An optional callback that will be invoked at the end of the run to deal with the artifacts from the run. + :param bool check_job_event_data: Check if job events data is completely generated. If event data is not completely generated and if + value is set to 'True' it will raise 'AnsibleRunnerException' exception. If set to 'False', log a debug message and continue execution. + Default value is 'False' + + :returns: A tuple of response and error string. The response is a dictionary object + (as returned by ansible-doc JSON output) containing each role found, or an empty dict + if none are found. + ''' + event_callback_handler = kwargs.pop('event_handler', None) + status_callback_handler = kwargs.pop('status_handler', None) + artifacts_handler = kwargs.pop('artifacts_handler', None) + cancel_callback = kwargs.pop('cancel_callback', None) + finished_callback = kwargs.pop('finished_callback', None) + + rd = DocConfig(**kwargs) + rd.prepare_role_list_command(collection, playbook_dir) + r = Runner(rd, + event_handler=event_callback_handler, + status_handler=status_callback_handler, + artifacts_handler=artifacts_handler, + cancel_callback=cancel_callback, + finished_callback=finished_callback) + r.run() + response = r.stdout.read() + error = r.stderr.read() + if response: + response = json.loads(sanitize_json_response(response)) + return response, error + + +def get_role_argspec(role, collection=None, playbook_dir=None, **kwargs): + ''' + Run an ``ansible-doc`` command to get a role argument specification. + + .. note:: Version added: 2.2 + + :param str role: Simple role name, or fully qualified collection role name, to query. + :param str collection: If specified, will be combined with the role name to form a fully qualified collection role name. + If this is supplied, the ``role`` param should not be fully qualified. + :param str playbook_dir: This parameter is used to set the relative path to handle playbook adjacent installed roles. + + :param str runner_mode: The applicable values are ``pexpect`` and ``subprocess``. Default is set to ``subprocess``. + :param str host_cwd: The host current working directory to be mounted within the container (if enabled) and will be + the work directory within container. + :param dict envvars: Environment variables to be used when running Ansible. Environment variables will also be + read from ``env/envvars`` in ``private_data_dir`` + :param dict passwords: A dictionary containing password prompt patterns and response values used when processing output from + Ansible. Passwords will also be read from ``env/passwords`` in ``private_data_dir``. + :param dict settings: A dictionary containing settings values for the ``ansible-runner`` runtime environment. These will also + be read from ``env/settings`` in ``private_data_dir``. + :param str ssh_key: The ssh private key passed to ``ssh-agent`` as part of the ansible-playbook run. + :param bool quiet: Disable all output + :param bool json_mode: Store event data in place of stdout on the console and in the stdout file + :param str artifact_dir: The path to the directory where artifacts should live, this defaults to 'artifacts' under the private data dir + :param str project_dir: The path to the playbook content, this defaults to 'project' within the private data dir + :param int rotate_artifacts: Keep at most n artifact directories, disable with a value of 0 which is the default + :param int timeout: The timeout value in seconds that will be passed to either ``pexpect`` of ``subprocess`` invocation + (based on ``runner_mode`` selected) while executing command. If the timeout is triggered, it will force cancel the execution. + :param bool process_isolation: Enable process isolation using a container engine, such as podman. + :param str process_isolation_executable: Process isolation executable or container engine used to isolate execution. (default: podman) + :param str container_image: Container image to use when running an Ansible task (default: quay.io/ansible/ansible-runner:devel) + :param list container_volume_mounts: List of bind mounts in the form ``host_dir:/container_dir:labels``. (default: None) + :param list container_options: List of container options to pass to execution engine. + :param str container_workdir: The working directory within the container. + :param str fact_cache: A string that will be used as the name for the subdirectory of the fact cache in artifacts directory. + This is only used for 'jsonfile' type fact caches. + :param str fact_cache_type: A string of the type of fact cache to use. Defaults to 'jsonfile'. + :param str private_data_dir: The directory containing all runner metadata needed to invoke the runner + module. Output artifacts will also be stored here for later consumption. + :param str ident: The run identifier for this invocation of Runner. Will be used to create and name + the artifact directory holding the results of the invocation. + :param function event_handler: An optional callback that will be invoked any time an event is received by Runner itself, return True to keep the event + :param function cancel_callback: An optional callback that can inform runner to cancel (returning True) or not (returning False) + :param function finished_callback: An optional callback that will be invoked at shutdown after process cleanup. + :param function status_handler: An optional callback that will be invoked any time the status changes + (for example: started, running, failed, successful, timeout) + :param function artifacts_handler: An optional callback that will be invoked at the end of the run to deal with the artifacts from the run. + :param bool check_job_event_data: Check if job events data is completely generated. If event data is not completely generated and if + value is set to 'True' it will raise 'AnsibleRunnerException' exception. If set to 'False', log a debug message and continue execution. + Default value is 'False' + + :returns: A tuple of response and error string. The response is a dictionary object + (as returned by ansible-doc JSON output) containing each role found, or an empty dict + if none are found. + ''' + event_callback_handler = kwargs.pop('event_handler', None) + status_callback_handler = kwargs.pop('status_handler', None) + artifacts_handler = kwargs.pop('artifacts_handler', None) + cancel_callback = kwargs.pop('cancel_callback', None) + finished_callback = kwargs.pop('finished_callback', None) + + rd = DocConfig(**kwargs) + rd.prepare_role_argspec_command(role, collection, playbook_dir) + r = Runner(rd, + event_handler=event_callback_handler, + status_handler=status_callback_handler, + artifacts_handler=artifacts_handler, + cancel_callback=cancel_callback, + finished_callback=finished_callback) + r.run() + response = r.stdout.read() + error = r.stderr.read() + if response: + response = json.loads(sanitize_json_response(response)) + return response, error diff --git a/ansible_runner/utils/__init__.py b/ansible_runner/utils/__init__.py index cde9525ed..79b87ae2d 100644 --- a/ansible_runner/utils/__init__.py +++ b/ansible_runner/utils/__init__.py @@ -454,7 +454,7 @@ def cli_mounts(): ] -def santize_json_response(data): +def sanitize_json_response(data): ''' Removes warning message from response message emitted by ansible command line utilities. diff --git a/docs/python_interface.rst b/docs/python_interface.rst index 06719e0d0..a35a57609 100644 --- a/docs/python_interface.rst +++ b/docs/python_interface.rst @@ -99,6 +99,33 @@ and it can be customized to return only the changed configuration value by setti view of the active configuration file. The exectuin will be in the foreground and return a tuple of output and error response when finished. While running the command within the container the current local working diretory will be volume mounted within the container. +``get_role_list()`` helper function +----------------------------------- + +:meth:`ansible_runner.interface.get_role_list` + +*Version added: 2.2* + +This function will execute the ``ansible-doc`` command to return the list of installed roles +that have an argument specification defined. This data can be fetched from either the local +environment or from within a container image based on the parameters passed. It will run in +the foreground and return a tuple of output and error response when finished. Successful output +will be in JSON format as returned from ``ansible-doc``. + +``get_role_argspec()`` helper function +-------------------------------------- + +:meth:`ansible_runner.interface.get_role_argspec` + +*Version added: 2.2* + +This function will execute the ``ansible-doc`` command to return a role argument specification. +This data can be fetched from either the local environment or from within a container image +based on the parameters passed. It will run in the foreground and return a tuple of output +and error response when finished. Successful output will be in JSON format as returned from +``ansible-doc``. + + The ``Runner`` object --------------------- @@ -301,6 +328,34 @@ Usage examples print("out: {}".format(out)) print("err: {}".format(err)) +.. code-block:: python + + # get all roles with an arg spec installed locally + out, err = ansible_runner.get_role_list() + print("out: {}".format(out)) + print("err: {}".format(err)) + +.. code-block:: python + + # get roles with an arg spec from the `foo.bar` collection in a container + out, err = ansible_runner.get_role_list(collection='foo.bar', process_isolation=True, container_image='network-ee') + print("out: {}".format(out)) + print("err: {}".format(err)) + +.. code-block:: python + + # get the arg spec for role `baz` from the locally installed `foo.bar` collection + out, err = ansible_runner.get_role_argspec('baz', collection='foo.bar') + print("out: {}".format(out)) + print("err: {}".format(err)) + +.. code-block:: python + + # get the arg spec for role `baz` from the `foo.bar` collection installed in a container + out, err = ansible_runner.get_role_argspec('baz', collection='foo.bar', process_isolation=True, container_image='network-ee') + print("out: {}".format(out)) + print("err: {}".format(err)) + Providing custom behavior and inputs ------------------------------------ diff --git a/test/conftest.py b/test/conftest.py index 594c0d6ec..6e361811b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -27,12 +27,34 @@ def is_pre_ansible28(): pass +@pytest.fixture(scope='session') +def is_pre_ansible211(): + """ + Check if the version of Ansible is less than 2.11. + + CI tests with either ansible-core (>=2.11), ansible-base (==2.10), and ansible (<=2.9). + """ + + try: + if pkg_resources.get_distribution('ansible-core').version: + return False + except pkg_resources.DistributionNotFound: + # Must be ansible-base or ansible + return True + + @pytest.fixture(scope='session') def skipif_pre_ansible28(is_pre_ansible28): if is_pre_ansible28: pytest.skip("Valid only on Ansible 2.8+") +@pytest.fixture(scope='session') +def skipif_pre_ansible211(is_pre_ansible211): + if is_pre_ansible211: + pytest.skip("Valid only on Ansible 2.11+") + + # TODO: determine if we want to add docker / podman # to zuul instances in order to run these tests def pytest_generate_tests(metafunc): diff --git a/test/integration/fixtures/projects/music/project/roles/Into_The_Mystic/meta/argument_specs.yml b/test/integration/fixtures/projects/music/project/roles/Into_The_Mystic/meta/argument_specs.yml new file mode 100644 index 000000000..5d6c0639f --- /dev/null +++ b/test/integration/fixtures/projects/music/project/roles/Into_The_Mystic/meta/argument_specs.yml @@ -0,0 +1,17 @@ +--- +argument_specs: + main: + short_description: The main entry point for the Into_The_Mystic role. + options: + foghorn: + type: "bool" + required: false + default: true + description: "If true, the foghorn blows." + soul: + type: "str" + required: true + description: "Type of soul to rock" + choices: + - "gypsy" + - "normal" diff --git a/test/integration/test_interface.py b/test/integration/test_interface.py index d1d5298c9..36dd22e70 100644 --- a/test/integration/test_interface.py +++ b/test/integration/test_interface.py @@ -2,8 +2,19 @@ import pytest from ansible_runner import defaults -from ansible_runner.interface import run, run_async, run_command, run_command_async, get_plugin_docs, \ - get_plugin_docs_async, get_plugin_list, get_ansible_config, get_inventory +from ansible_runner.interface import ( + get_ansible_config, + get_inventory, + get_plugin_docs, + get_plugin_docs_async, + get_plugin_list, + get_role_argspec, + get_role_list, + run, + run_async, + run_command, + run_command_async, +) def test_run(): @@ -355,3 +366,126 @@ def test_run_role(project_fixtures): stdout = res.stdout.read() assert res.rc == 0, stdout assert 'Hello World!' in stdout + + +def test_get_role_list(project_fixtures, skipif_pre_ansible211): + """ + Test get_role_list() running locally, specifying a playbook directory + containing our test role. + """ + pdir = str(project_fixtures / 'music' / 'project') + expected_role = { + "collection": "", + "entry_points": { + "main": "The main entry point for the Into_The_Mystic role." + } + } + + resp, err = get_role_list(playbook_dir=pdir) + assert isinstance(resp, dict) + + # So that tests can work locally, where multiple roles might be returned, + # we check for this single role. + assert 'Into_The_Mystic' in resp + assert resp['Into_The_Mystic'] == expected_role + + +@pytest.mark.test_all_runtimes +def test_get_role_list_within_container(project_fixtures, runtime, skipif_pre_ansible211): + """ + Test get_role_list() running in a container. + """ + pdir = str(project_fixtures / 'music') + expected = { + "Into_The_Mystic": { + "collection": "", + "entry_points": { + "main": "The main entry point for the Into_The_Mystic role." + } + } + } + container_kwargs = { + 'process_isolation_executable': runtime, + 'process_isolation': True, + 'container_image': defaults.default_container_image + } + resp, err = get_role_list(private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs) + assert isinstance(resp, dict) + assert resp == expected + + +def test_get_role_argspec(project_fixtures, skipif_pre_ansible211): + """ + Test get_role_argspec() running locally, specifying a playbook directory + containing our test role. + """ + use_role_example = str(project_fixtures / 'music' / 'project') + expected_epoint = { + "main": { + "options": { + "foghorn": { + "default": True, + "description": "If true, the foghorn blows.", + "required": False, + "type": "bool" + }, + "soul": { + "choices": [ + "gypsy", + "normal" + ], + "description": "Type of soul to rock", + "required": True, + "type": "str" + } + }, + "short_description": "The main entry point for the Into_The_Mystic role." + } + } + + resp, err = get_role_argspec('Into_The_Mystic', playbook_dir=use_role_example) + assert isinstance(resp, dict) + assert 'Into_The_Mystic' in resp + assert resp['Into_The_Mystic']['entry_points'] == expected_epoint + + +@pytest.mark.test_all_runtimes +def test_get_role_argspec_within_container(project_fixtures, runtime, skipif_pre_ansible211): + """ + Test get_role_argspec() running inside a container. Since the test container + does not currently contain any collections or roles, specify playbook_dir + pointing to the project dir of private_data_dir so that we will find a role. + """ + pdir = str(project_fixtures / 'music') + expected_epoint = { + "main": { + "options": { + "foghorn": { + "default": True, + "description": "If true, the foghorn blows.", + "required": False, + "type": "bool" + }, + "soul": { + "choices": [ + "gypsy", + "normal" + ], + "description": "Type of soul to rock", + "required": True, + "type": "str" + } + }, + "short_description": "The main entry point for the Into_The_Mystic role." + } + } + + container_kwargs = { + 'process_isolation_executable': runtime, + 'process_isolation': True, + 'container_image': defaults.default_container_image + } + resp, err = get_role_argspec('Into_The_Mystic', private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs) + assert isinstance(resp, dict) + assert 'Into_The_Mystic' in resp + assert resp['Into_The_Mystic']['entry_points'] == expected_epoint