summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/ansible/cli/arguments/option_helpers.py3
-rw-r--r--lib/ansible/cli/doc.py350
-rw-r--r--test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole/meta/argument_specs.yml22
-rw-r--r--test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole_with_no_argspecs/meta/empty0
-rw-r--r--test/integration/targets/ansible-doc/fakecollrole.output16
-rw-r--r--test/integration/targets/ansible-doc/fakerole.output32
-rw-r--r--test/integration/targets/ansible-doc/roles/test_role1/meta/argument_specs.yml33
-rw-r--r--test/integration/targets/ansible-doc/roles/test_role2/meta/empty0
-rwxr-xr-xtest/integration/targets/ansible-doc/runme.sh34
-rw-r--r--test/integration/targets/ansible-doc/test_role1/README.txt3
-rw-r--r--test/integration/targets/ansible-doc/test_role1/meta/argument_specs.yml4
-rw-r--r--test/integration/targets/ansible/playbookdir_cfg.ini2
-rwxr-xr-xtest/integration/targets/ansible/runme.sh6
13 files changed, 494 insertions, 11 deletions
diff --git a/lib/ansible/cli/arguments/option_helpers.py b/lib/ansible/cli/arguments/option_helpers.py
index 8465f7510b..043b7b5d48 100644
--- a/lib/ansible/cli/arguments/option_helpers.py
+++ b/lib/ansible/cli/arguments/option_helpers.py
@@ -221,7 +221,8 @@ def add_basedir_options(parser):
"""Add options for commands which can set a playbook basedir"""
parser.add_argument('--playbook-dir', default=C.config.get_config_value('PLAYBOOK_DIR'), dest='basedir', action='store',
help="Since this tool does not use playbooks, use this as a substitute playbook directory."
- "This sets the relative path for many features including roles/ group_vars/ etc.")
+ "This sets the relative path for many features including roles/ group_vars/ etc.",
+ type=unfrack_path())
def add_check_options(parser):
diff --git a/lib/ansible/cli/doc.py b/lib/ansible/cli/doc.py
index 8372e330d1..e1299a9032 100644
--- a/lib/ansible/cli/doc.py
+++ b/lib/ansible/cli/doc.py
@@ -9,6 +9,7 @@ import datetime
import json
import pkgutil
import os
+import os.path
import re
import textwrap
import traceback
@@ -26,12 +27,13 @@ from ansible.module_utils._text import to_native, to_text
from ansible.module_utils.common._collections_compat import Container, Sequence
from ansible.module_utils.common.json import AnsibleJSONEncoder
from ansible.module_utils.compat import importlib
-from ansible.module_utils.six import string_types
+from ansible.module_utils.six import iteritems, string_types
+from ansible.parsing.dataloader import DataLoader
from ansible.parsing.plugin_docs import read_docstub
from ansible.parsing.utils.yaml import from_yaml
from ansible.parsing.yaml.dumper import AnsibleDumper
from ansible.plugins.loader import action_loader, fragment_loader
-from ansible.utils.collection_loader import AnsibleCollectionConfig
+from ansible.utils.collection_loader import AnsibleCollectionConfig, AnsibleCollectionRef
from ansible.utils.collection_loader._collection_finder import _get_collection_name_from_path
from ansible.utils.display import Display
from ansible.utils.plugin_docs import (
@@ -44,7 +46,7 @@ from ansible.utils.plugin_docs import (
display = Display()
-TARGET_OPTIONS = C.DOCUMENTABLE_PLUGINS + ('keyword',)
+TARGET_OPTIONS = C.DOCUMENTABLE_PLUGINS + ('role', 'keyword',)
PB_OBJECTS = ['Play', 'Role', 'Block', 'Task']
PB_LOADED = {}
@@ -71,7 +73,206 @@ class PluginNotFound(Exception):
pass
-class DocCLI(CLI):
+class RoleMixin(object):
+ """A mixin containing all methods relevant to role argument specification functionality.
+
+ Note: The methods for actual display of role data are not present here.
+ """
+
+ ROLE_ARGSPEC_FILE = 'argument_specs.yml'
+
+ def _load_argspec(self, role_name, collection_path=None, role_path=None):
+ if collection_path:
+ path = os.path.join(collection_path, 'roles', role_name, 'meta', self.ROLE_ARGSPEC_FILE)
+ elif role_path:
+ path = os.path.join(role_path, 'meta', self.ROLE_ARGSPEC_FILE)
+ else:
+ raise AnsibleError("A path is required to load argument specs for role '%s'" % role_name)
+
+ loader = DataLoader()
+ return loader.load_from_file(path, cache=False, unsafe=True)
+
+ def _find_all_normal_roles(self, role_paths, name_filters=None):
+ """Find all non-collection roles that have an argument spec file.
+
+ :param role_paths: A tuple of one or more role paths. When a role with the same name
+ is found in multiple paths, only the first-found role is returned.
+ :param name_filters: A tuple of one or more role names used to filter the results.
+
+ :returns: A set of tuples consisting of: role name, full role path
+ """
+ found = set()
+ found_names = set()
+ for path in role_paths:
+ if not os.path.isdir(path):
+ continue
+ # Check each subdir for a meta/argument_specs.yml file
+ for entry in os.listdir(path):
+ role_path = os.path.join(path, entry)
+ full_path = os.path.join(role_path, 'meta', self.ROLE_ARGSPEC_FILE)
+ if os.path.exists(full_path):
+ if name_filters is None or entry in name_filters:
+ if entry not in found_names:
+ found.add((entry, role_path))
+ found_names.add(entry)
+ return found
+
+ def _find_all_collection_roles(self, name_filters=None, collection_filter=None):
+ """Find all collection roles with an argument spec.
+
+ :param name_filters: A tuple of one or more role names used to filter the results. These
+ might be fully qualified with the collection name (e.g., community.general.roleA)
+ or not (e.g., roleA).
+
+ :param collection_filter: A string containing the FQCN of a collection which will be
+ used to limit results. This filter will take precedence over the name_filters.
+
+ :returns: A set of tuples consisting of: role name, collection name, collection path
+ """
+ found = set()
+ b_colldirs = list_collection_dirs(coll_filter=collection_filter)
+ for b_path in b_colldirs:
+ path = to_text(b_path, errors='surrogate_or_strict')
+ collname = _get_collection_name_from_path(b_path)
+
+ roles_dir = os.path.join(path, 'roles')
+ if os.path.exists(roles_dir):
+ for entry in os.listdir(roles_dir):
+ full_path = os.path.join(roles_dir, entry, 'meta', self.ROLE_ARGSPEC_FILE)
+ if os.path.exists(full_path):
+ if name_filters is None:
+ found.add((entry, collname, path))
+ else:
+ # Name filters might contain a collection FQCN or not.
+ for fqcn in name_filters:
+ if len(fqcn.split('.')) == 3:
+ (ns, col, role) = fqcn.split('.')
+ if '.'.join([ns, col]) == collname and entry == role:
+ found.add((entry, collname, path))
+ elif fqcn == entry:
+ found.add((entry, collname, path))
+ return found
+
+ def _build_summary(self, role, collection, argspec):
+ """Build a summary dict for a role.
+
+ Returns a simplified role arg spec containing only the role entry points and their
+ short descriptions, and the role collection name (if applicable).
+
+ :param role: The simple role name.
+ :param collection: The collection containing the role (None or empty string if N/A).
+ :param argspec: The complete role argspec data dict.
+
+ :returns: A tuple with the FQCN role name and a summary dict.
+ """
+ if collection:
+ fqcn = '.'.join([collection, role])
+ else:
+ fqcn = role
+ summary = {}
+ summary['collection'] = collection
+ summary['entry_points'] = {}
+ for entry_point in argspec.keys():
+ entry_spec = argspec[entry_point] or {}
+ summary['entry_points'][entry_point] = entry_spec.get('short_description', '')
+ return (fqcn, summary)
+
+ def _create_role_list(self, roles_path, collection_filter=None):
+ """Return a dict describing the listing of all roles with arg specs.
+
+ :param role_paths: A tuple of one or more role paths.
+
+ :returns: A dict indexed by role name, with 'collection' and 'entry_points' keys per role.
+
+ Example return:
+
+ results = {
+ 'roleA': {
+ 'collection': '',
+ 'entry_points': {
+ 'main': 'Short description for main'
+ }
+ },
+ 'a.b.c.roleB': {
+ 'collection': 'a.b.c',
+ 'entry_points': {
+ 'main': 'Short description for main',
+ 'alternate': 'Short description for alternate entry point'
+ }
+ 'x.y.z.roleB': {
+ 'collection': 'x.y.z',
+ 'entry_points': {
+ 'main': 'Short description for main',
+ }
+ },
+ }
+ """
+ if not collection_filter:
+ roles = self._find_all_normal_roles(roles_path)
+ else:
+ roles = []
+ collroles = self._find_all_collection_roles(collection_filter=collection_filter)
+
+ result = {}
+
+ for role, role_path in roles:
+ argspec = self._load_argspec(role, role_path=role_path)
+ fqcn, summary = self._build_summary(role, '', argspec)
+ result[fqcn] = summary
+
+ for role, collection, collection_path in collroles:
+ argspec = self._load_argspec(role, collection_path=collection_path)
+ fqcn, summary = self._build_summary(role, collection, argspec)
+ result[fqcn] = summary
+
+ return result
+
+ def _create_role_doc(self, role_names, roles_path, entry_point=None):
+ """
+ :param role_names: A tuple of one or more role names.
+ :param role_paths: A tuple of one or more role paths.
+ :param entry_point: A role entry point name for filtering.
+
+ :returns: A dict indexed by role name, with 'collection', 'entry_points', and 'path' keys per role.
+ """
+ roles = self._find_all_normal_roles(roles_path, name_filters=role_names)
+ collroles = self._find_all_collection_roles(name_filters=role_names)
+ result = {}
+
+ def build_doc(role, path, collection, argspec):
+ if collection:
+ fqcn = '.'.join([collection, role])
+ else:
+ fqcn = role
+ if fqcn not in result:
+ result[fqcn] = {}
+ doc = {}
+ doc['path'] = path
+ doc['collection'] = collection
+ doc['entry_points'] = {}
+ for ep in argspec.keys():
+ if entry_point is None or ep == entry_point:
+ entry_spec = argspec[ep] or {}
+ doc['entry_points'][ep] = entry_spec
+
+ # If we didn't add any entry points (b/c of filtering), remove this entry.
+ if len(doc['entry_points'].keys()) == 0:
+ del result[fqcn]
+ else:
+ result[fqcn] = doc
+
+ for role, role_path in roles:
+ argspec = self._load_argspec(role, role_path=role_path)
+ build_doc(role, role_path, '', argspec)
+
+ for role, collection, collection_path in collroles:
+ argspec = self._load_argspec(role, collection_path=collection_path)
+ build_doc(role, collection_path, collection, argspec)
+
+ return result
+
+
+class DocCLI(CLI, RoleMixin):
''' displays information on modules installed in Ansible libraries.
It displays a terse listing of plugins and their short descriptions,
provides a printout of their DOCUMENTATION strings,
@@ -141,6 +342,12 @@ class DocCLI(CLI):
self.parser.add_argument("-j", "--json", action="store_true", default=False, dest='json_format',
help='Change output into json format.')
+ # role-specific options
+ self.parser.add_argument("-r", "--roles-path", dest='roles_path', default=C.DEFAULT_ROLES_PATH,
+ type=opt_help.unfrack_path(pathsep=True),
+ action=opt_help.PrependListAction,
+ help='The path to the directory containing your roles.')
+
exclusive = self.parser.add_mutually_exclusive_group()
exclusive.add_argument("-F", "--list_files", action="store_true", default=False, dest="list_files",
help='Show plugin names and their source files without summaries (implies --list). %s' % coll_filter)
@@ -150,6 +357,8 @@ class DocCLI(CLI):
help='Show playbook snippet for specified plugin(s)')
exclusive.add_argument("--metadata-dump", action="store_true", default=False, dest='dump',
help='**For internal testing only** Dump json metadata for all plugins.')
+ exclusive.add_argument("-e", "--entry-point", dest="entry_point",
+ help="Select the entry point for role(s).")
def post_process_args(self, options):
options = super(DocCLI, self).post_process_args(options)
@@ -192,6 +401,48 @@ class DocCLI(CLI):
# display results
DocCLI.pager("\n".join(text))
+ def _display_available_roles(self, list_json):
+ """Display all roles we can find with a valid argument specification.
+
+ Output is: fqcn role name, entry point, short description
+ """
+ roles = list(list_json.keys())
+ entry_point_names = set()
+ for role in roles:
+ for entry_point in list_json[role]['entry_points'].keys():
+ entry_point_names.add(entry_point)
+
+ max_role_len = 0
+ max_ep_len = 0
+
+ if roles:
+ max_role_len = max(len(x) for x in roles)
+ if entry_point_names:
+ max_ep_len = max(len(x) for x in entry_point_names)
+
+ linelimit = display.columns - max_role_len - max_ep_len - 5
+ text = []
+
+ for role in sorted(roles):
+ for entry_point, desc in iteritems(list_json[role]['entry_points']):
+ if len(desc) > linelimit:
+ desc = desc[:linelimit] + '...'
+ text.append("%-*s %-*s %s" % (max_role_len, role,
+ max_ep_len, entry_point,
+ desc))
+
+ # display results
+ DocCLI.pager("\n".join(text))
+
+ def _display_role_doc(self, role_json):
+ roles = list(role_json.keys())
+ text = []
+ for role in roles:
+ text += self.get_role_man_text(role, role_json[role])
+
+ # display results
+ DocCLI.pager("\n".join(text))
+
@staticmethod
def _list_keywords():
return from_yaml(pkgutil.get_data('ansible', 'keyword_desc.yml'))
@@ -315,11 +566,27 @@ class DocCLI(CLI):
super(DocCLI, self).run()
+ basedir = context.CLIARGS['basedir']
plugin_type = context.CLIARGS['type']
do_json = context.CLIARGS['json_format']
+ roles_path = context.CLIARGS['roles_path']
listing = context.CLIARGS['list_files'] or context.CLIARGS['list_dir'] or context.CLIARGS['dump']
docs = {}
+ if basedir:
+ AnsibleCollectionConfig.playbook_paths = basedir
+
+ # Add any 'roles' subdir in playbook dir to the roles search path.
+ # And as a last resort, add the playbook dir itself. Order being:
+ # - 'roles' subdir of playbook dir
+ # - DEFAULT_ROLES_PATH
+ # - playbook dir
+ # NOTE: This matches logic in RoleDefinition._load_role_path() method.
+ subdir = os.path.join(basedir, "roles")
+ if os.path.isdir(subdir):
+ roles_path = (subdir,) + roles_path
+ roles_path = roles_path + (basedir,)
+
if plugin_type not in TARGET_OPTIONS:
raise AnsibleOptionsError("Unknown or undocumentable plugin type: %s" % plugin_type)
elif plugin_type == 'keyword':
@@ -328,6 +595,20 @@ class DocCLI(CLI):
docs = DocCLI._list_keywords()
else:
docs = DocCLI._get_keywords_docs(context.CLIARGS['args'])
+ elif plugin_type == 'role':
+ if context.CLIARGS['list_dir']:
+ # If an argument was given with --list, it is a collection filter
+ coll_filter = None
+ if len(context.CLIARGS['args']) == 1:
+ coll_filter = context.CLIARGS['args'][0]
+ if not AnsibleCollectionRef.is_valid_collection_name(coll_filter):
+ raise AnsibleError('Invalid collection name (must be of the form namespace.collection): {0}'.format(coll_filter))
+ elif len(context.CLIARGS['args']) > 1:
+ raise AnsibleOptionsError("Only a single collection filter is supported.")
+
+ docs = self._create_role_list(roles_path, collection_filter=coll_filter)
+ else:
+ docs = self._create_role_doc(context.CLIARGS['args'], roles_path, context.CLIARGS['entry_point'])
else:
loader = getattr(plugin_loader, '%s_loader' % plugin_type)
@@ -367,6 +648,11 @@ class DocCLI(CLI):
text.append(textret)
else:
display.warning("No valid documentation was retrieved from '%s'" % plugin)
+ elif plugin_type == 'role':
+ if context.CLIARGS['list_dir'] and docs:
+ self._display_available_roles(docs)
+ elif docs:
+ self._display_role_doc(docs)
elif docs:
text = DocCLI._dump_yaml(docs, '')
@@ -713,6 +999,62 @@ class DocCLI(CLI):
if not suboptions:
text.append('')
+ def get_role_man_text(self, role, role_json):
+ '''Generate text for the supplied role suitable for display.
+
+ This is similar to get_man_text(), but roles are different enough that we have
+ a separate method for formatting their display.
+
+ :param role: The role name.
+ :param role_json: The JSON for the given role as returned from _create_role_doc().
+
+ :returns: A array of text suitable for displaying to screen.
+ '''
+ text = []
+ opt_indent = " "
+ pad = display.columns * 0.20
+ limit = max(display.columns - int(pad), 70)
+
+ text.append("> %s (%s)\n" % (role.upper(), role_json.get('path')))
+
+ for entry_point in role_json['entry_points']:
+ doc = role_json['entry_points'][entry_point]
+
+ if doc.get('short_description'):
+ text.append("ENTRY POINT: %s - %s\n" % (entry_point, doc.get('short_description')))
+ else:
+ text.append("ENTRY POINT: %s\n" % entry_point)
+
+ if doc.get('description'):
+ if isinstance(doc['description'], list):
+ desc = " ".join(doc['description'])
+ else:
+ desc = doc['description']
+
+ text.append("%s\n" % textwrap.fill(DocCLI.tty_ify(desc),
+ limit, initial_indent=opt_indent,
+ subsequent_indent=opt_indent))
+ if doc.get('options'):
+ text.append("OPTIONS (= is mandatory):\n")
+ DocCLI.add_fields(text, doc.pop('options'), limit, opt_indent)
+ text.append('')
+
+ # generic elements we will handle identically
+ for k in ('author',):
+ if k not in doc:
+ continue
+ if isinstance(doc[k], string_types):
+ text.append('%s: %s' % (k.upper(), textwrap.fill(DocCLI.tty_ify(doc[k]),
+ limit - (len(k) + 2), subsequent_indent=opt_indent)))
+ elif isinstance(doc[k], (list, tuple)):
+ text.append('%s: %s' % (k.upper(), ', '.join(doc[k])))
+ else:
+ # use empty indent since this affects the start of the yaml doc, not it's keys
+ text.append(DocCLI._dump_yaml({k.upper(): doc[k]}, ''))
+ text.append('')
+
+ return text
+
@staticmethod
def get_man_text(doc, collection_name='', plugin_type=''):
# Create a copy so we don't modify the original
diff --git a/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole/meta/argument_specs.yml b/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole/meta/argument_specs.yml
new file mode 100644
index 0000000000..5b1b7049d2
--- /dev/null
+++ b/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole/meta/argument_specs.yml
@@ -0,0 +1,22 @@
+---
+main:
+ short_description: testns.testcol.testrole short description for main entry point
+ description:
+ - Longer description for testns.testcol.testrole main entry point.
+ author: Ansible Core (@ansible)
+ options:
+ opt1:
+ description: opt1 description
+ type: "str"
+ required: true
+
+alternate:
+ short_description: testns.testcol.testrole short description for alternate entry point
+ description:
+ - Longer description for testns.testcol.testrole alternate entry point.
+ author: Ansible Core (@ansible)
+ options:
+ altopt1:
+ description: altopt1 description
+ type: "int"
+ required: true
diff --git a/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole_with_no_argspecs/meta/empty b/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole_with_no_argspecs/meta/empty
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/roles/testrole_with_no_argspecs/meta/empty
diff --git a/test/integration/targets/ansible-doc/fakecollrole.output b/test/integration/targets/ansible-doc/fakecollrole.output
new file mode 100644
index 0000000000..fdd6a2dda6
--- /dev/null
+++ b/test/integration/targets/ansible-doc/fakecollrole.output
@@ -0,0 +1,16 @@
+> TESTNS.TESTCOL.TESTROLE (/ansible/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol)
+
+ENTRY POINT: alternate - testns.testcol.testrole short description for alternate entry point
+
+ Longer description for testns.testcol.testrole alternate entry
+ point.
+
+OPTIONS (= is mandatory):
+
+= altopt1
+ altopt1 description
+
+ type: int
+
+
+AUTHOR: Ansible Core (@ansible)
diff --git a/test/integration/targets/ansible-doc/fakerole.output b/test/integration/targets/ansible-doc/fakerole.output
new file mode 100644
index 0000000000..81db9a63fe
--- /dev/null
+++ b/test/integration/targets/ansible-doc/fakerole.output
@@ -0,0 +1,32 @@
+> TEST_ROLE1 (/ansible/test/integration/targets/ansible-doc/roles/normal_role1)
+
+ENTRY POINT: main - test_role1 from roles subdir
+
+ In to am attended desirous raptures *declared* diverted
+ confined at. Collected instantly remaining up certainly to
+ `necessary' as. Over walk dull into son boy door went new. At
+ or happiness commanded daughters as. Is `handsome' an declared
+ at received in extended vicinity subjects. Into miss on he
+ over been late pain an. Only week bore boy what fat case left
+ use. Match round scale now style far times. Your me past an
+ much.
+
+OPTIONS (= is mandatory):
+
+= myopt1
+ First option.
+
+ type: str
+
+- myopt2
+ Second option
+ [Default: 8000]
+ type: int
+
+- myopt3
+ Third option.
+ (Choices: choice1, choice2)[Default: (null)]
+ type: str
+
+
+AUTHOR: John Doe (@john), Jane Doe (@jane)
diff --git a/test/integration/targets/ansible-doc/roles/test_role1/meta/argument_specs.yml b/test/integration/targets/ansible-doc/roles/test_role1/meta/argument_specs.yml
new file mode 100644
index 0000000000..6fa9c74cbf
--- /dev/null
+++ b/test/integration/targets/ansible-doc/roles/test_role1/meta/argument_specs.yml
@@ -0,0 +1,33 @@
+---
+main:
+ short_description: test_role1 from roles subdir
+ description:
+ - In to am attended desirous raptures B(declared) diverted confined at. Collected instantly remaining
+ up certainly to C(necessary) as. Over walk dull into son boy door went new.
+ - At or happiness commanded daughters as. Is I(handsome) an declared at received in extended vicinity
+ subjects. Into miss on he over been late pain an. Only week bore boy what fat case left use. Match round
+ scale now style far times. Your me past an much.
+ author:
+ - John Doe (@john)
+ - Jane Doe (@jane)
+
+ options:
+ myopt1:
+ description:
+ - First option.
+ type: "str"
+ required: true
+
+ myopt2:
+ description:
+ - Second option
+ type: "int"
+ default: 8000
+
+ myopt3:
+ description:
+ - Third option.
+ type: "str"
+ choices:
+ - choice1
+ - choice2
diff --git a/test/integration/targets/ansible-doc/roles/test_role2/meta/empty b/test/integration/targets/ansible-doc/roles/test_role2/meta/empty
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/test/integration/targets/ansible-doc/roles/test_role2/meta/empty
diff --git a/test/integration/targets/ansible-doc/runme.sh b/test/integration/targets/ansible-doc/runme.sh
index c4bea83d4c..4d5e915cb6 100755
--- a/test/integration/targets/ansible-doc/runme.sh
+++ b/test/integration/targets/ansible-doc/runme.sh
@@ -14,8 +14,9 @@ unset ANSIBLE_PLAYBOOK_DIR
cd "$(dirname "$0")"
# test module docs from collection
-current_out="$(ansible-doc --playbook-dir ./ testns.testcol.fakemodule)"
-expected_out="$(cat fakemodule.output)"
+# we use sed to strip the module path from the first line
+current_out="$(ansible-doc --playbook-dir ./ testns.testcol.fakemodule | sed '1 s/\(^> TESTNS\.TESTCOL\.FAKEMODULE\).*(.*)$/\1/')"
+expected_out="$(sed '1 s/\(^> TESTNS\.TESTCOL\.FAKEMODULE\).*(.*)$/\1/' fakemodule.output)"
test "$current_out" == "$expected_out"
# ensure we do work with valid collection name for list
@@ -45,4 +46,33 @@ do
justcol=$(ansible-doc -l -t ${ptype} --playbook-dir ./ testns|wc -l)
test "$justcol" -eq 1
done
+
+#### test role functionality
+
+# Test role text output
+# we use sed to strip the role path from the first line
+current_role_out="$(ansible-doc -t role -r ./roles test_role1 | sed '1 s/\(^> TEST_ROLE1\).*(.*)$/\1/')"
+expected_role_out="$(sed '1 s/\(^> TEST_ROLE1\).*(.*)$/\1/' fakerole.output)"
+test "$current_role_out" == "$expected_role_out"
+
+# Two collection roles are defined, but only 1 has a role arg spec with 2 entry points
+output=$(ansible-doc -t role -l --playbook-dir . testns.testcol | wc -l)
+test "$output" -eq 2
+
+# Include normal roles (no collection filter)
+output=$(ansible-doc -t role -l --playbook-dir . | wc -l)
+test "$output" -eq 3
+
+# Test that a role in the playbook dir with the same name as a role in the
+# 'roles' subdir of the playbook dir does not appear (lower precedence).
+output=$(ansible-doc -t role -l --playbook-dir . | grep -c "test_role1 from roles subdir")
+test "$output" -eq 1
+output=$(ansible-doc -t role -l --playbook-dir . | grep -c "test_role1 from playbook dir" || true)
+test "$output" -eq 0
+
+# Test entry point filter
+current_role_out="$(ansible-doc -t role --playbook-dir . testns.testcol.testrole -e alternate| sed '1 s/\(^> TESTNS\.TESTCOL\.TESTROLE\).*(.*)$/\1/')"
+expected_role_out="$(sed '1 s/\(^> TESTNS\.TESTCOL\.TESTROLE\).*(.*)$/\1/' fakecollrole.output)"
+test "$current_role_out" == "$expected_role_out"
+
)
diff --git a/test/integration/targets/ansible-doc/test_role1/README.txt b/test/integration/targets/ansible-doc/test_role1/README.txt
new file mode 100644
index 0000000000..98983c8cee
--- /dev/null
+++ b/test/integration/targets/ansible-doc/test_role1/README.txt
@@ -0,0 +1,3 @@
+Test role that exists in the playbook directory so we can validate
+that a role of the same name that exists in the 'roles' subdirectory
+will take precedence over this one.
diff --git a/test/integration/targets/ansible-doc/test_role1/meta/argument_specs.yml b/test/integration/targets/ansible-doc/test_role1/meta/argument_specs.yml
new file mode 100644
index 0000000000..9a35758755
--- /dev/null
+++ b/test/integration/targets/ansible-doc/test_role1/meta/argument_specs.yml
@@ -0,0 +1,4 @@
+---
+main:
+ short_description: test_role1 from playbook dir
+ description: This should not appear in `ansible-doc --list` output.
diff --git a/test/integration/targets/ansible/playbookdir_cfg.ini b/test/integration/targets/ansible/playbookdir_cfg.ini
index f4bf8af895..16670c5b83 100644
--- a/test/integration/targets/ansible/playbookdir_cfg.ini
+++ b/test/integration/targets/ansible/playbookdir_cfg.ini
@@ -1,2 +1,2 @@
[defaults]
-playbook_dir = /tmp
+playbook_dir = /doesnotexist/tmp
diff --git a/test/integration/targets/ansible/runme.sh b/test/integration/targets/ansible/runme.sh
index 42130961eb..fc79e33e73 100755
--- a/test/integration/targets/ansible/runme.sh
+++ b/test/integration/targets/ansible/runme.sh
@@ -25,13 +25,13 @@ ansible-config view -c ./no-extension 2> err2.txt || grep -q 'Unsupported config
rm -f err*.txt
# test setting playbook_dir via envvar
-ANSIBLE_PLAYBOOK_DIR=/tmp ansible localhost -m debug -a var=playbook_dir | grep '"playbook_dir": "/tmp"'
+ANSIBLE_PLAYBOOK_DIR=/doesnotexist/tmp ansible localhost -m debug -a var=playbook_dir | grep '"playbook_dir": "/doesnotexist/tmp"'
# test setting playbook_dir via cmdline
-ansible localhost -m debug -a var=playbook_dir --playbook-dir=/tmp | grep '"playbook_dir": "/tmp"'
+ansible localhost -m debug -a var=playbook_dir --playbook-dir=/doesnotexist/tmp | grep '"playbook_dir": "/doesnotexist/tmp"'
# test setting playbook dir via ansible.cfg
-env -u ANSIBLE_PLAYBOOK_DIR ANSIBLE_CONFIG=./playbookdir_cfg.ini ansible localhost -m debug -a var=playbook_dir | grep '"playbook_dir": "/tmp"'
+env -u ANSIBLE_PLAYBOOK_DIR ANSIBLE_CONFIG=./playbookdir_cfg.ini ansible localhost -m debug -a var=playbook_dir | grep '"playbook_dir": "/doesnotexist/tmp"'
# test adhoc callback triggers
ANSIBLE_STDOUT_CALLBACK=callback_debug ANSIBLE_LOAD_CALLBACK_PLUGINS=1 ansible --playbook-dir . testhost -i ../../inventory -m ping | grep -E '^v2_' | diff -u adhoc-callback.stdout -