From 9a7e9571e66db359b4facba265aacc74aef1472e Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Fri, 21 May 2021 08:36:57 +0200 Subject: [PATCH] Read and generate role documentation. Co-authored-by: Alicia Cozine <879121+acozine@users.noreply.github.com> --- antsibull/augment_docs.py | 9 +- antsibull/cli/doc_commands/stable.py | 17 +- antsibull/constants.py | 11 +- antsibull/data/collection-enum.py | 41 +++ antsibull/data/docsite/list_of_plugins.rst.j2 | 3 + .../data/docsite/plugins_by_collection.rst.j2 | 19 +- antsibull/data/docsite/role.rst.j2 | 286 ++++++++++++++++++ antsibull/docs_parsing/ansible_doc.py | 21 +- antsibull/schemas/docs/__init__.py | 2 + antsibull/schemas/docs/ansible_doc.py | 15 + antsibull/schemas/docs/role.py | 60 ++++ antsibull/write_docs.py | 36 ++- .../functional/schema/good_data/one_role.json | 85 ++++++ .../schema/good_data/one_role_results.json | 85 ++++++ 14 files changed, 667 insertions(+), 23 deletions(-) create mode 100644 antsibull/data/docsite/role.rst.j2 create mode 100644 antsibull/schemas/docs/role.py create mode 100644 tests/functional/schema/good_data/one_role.json create mode 100644 tests/functional/schema/good_data/one_role_results.json diff --git a/antsibull/augment_docs.py b/antsibull/augment_docs.py index 69b2162b..58a67d91 100644 --- a/antsibull/augment_docs.py +++ b/antsibull/augment_docs.py @@ -56,5 +56,10 @@ def augment_docs(plugin_info: t.MutableMapping[str, t.MutableMapping[str, t.Any] """ for plugin_type, plugin_map in plugin_info.items(): for plugin_name, plugin_record in plugin_map.items(): - add_full_key(plugin_record['return'], 'contains') - add_full_key(plugin_record['doc']['options'], 'suboptions') + if 'return' in plugin_record: + add_full_key(plugin_record['return'], 'contains') + if 'doc' in plugin_record: + add_full_key(plugin_record['doc']['options'], 'suboptions') + if 'entry_points' in plugin_record: + for entry_point in plugin_record['entry_points'].values(): + add_full_key(entry_point['options'], 'options') diff --git a/antsibull/cli/doc_commands/stable.py b/antsibull/cli/doc_commands/stable.py index 53831c8c..6512cbaa 100644 --- a/antsibull/cli/doc_commands/stable.py +++ b/antsibull/cli/doc_commands/stable.py @@ -117,8 +117,14 @@ def normalize_plugin_info(plugin_type: str, in :mod:`antsibull.schemas.docs`. The nonfatal errors are strings representing the problems encountered. """ - new_info = {} errors = [] + if plugin_type == 'role': + try: + return DOCS_SCHEMAS[plugin_type].parse_obj(plugin_info).dict(by_alias=True), errors + except ValidationError as e: + raise ValueError(str(e)) + + new_info = {} # Note: loop through "doc" before any other keys. for field in ('doc', 'examples', 'return'): try: @@ -224,8 +230,13 @@ def get_plugin_contents(plugin_info: t.Mapping[str, t.Mapping[str, t.Any]], for plugin_type, plugin_list in plugin_info.items(): for plugin_name, plugin_desc in plugin_list.items(): namespace, collection, short_name = get_fqcn_parts(plugin_name) - plugin_contents[plugin_type]['.'.join((namespace, collection))][short_name] = ( - plugin_desc['doc']['short_description']) + if plugin_type == 'role': + desc = '' + if 'main' in plugin_desc['entry_points']: + desc = plugin_desc['entry_points']['main']['short_description'] + else: + desc = plugin_desc['doc']['short_description'] + plugin_contents[plugin_type]['.'.join((namespace, collection))][short_name] = desc return plugin_contents diff --git a/antsibull/constants.py b/antsibull/constants.py index 2787fb86..2e99f894 100644 --- a/antsibull/constants.py +++ b/antsibull/constants.py @@ -4,16 +4,21 @@ # Copyright: Ansible Project, 2020 """Constant values for use throughout the antsibull codebase.""" -from typing import FrozenSet +from typing import Dict, FrozenSet #: All the types of ansible plugins PLUGIN_TYPES: FrozenSet[str] = frozenset(('become', 'cache', 'callback', 'cliconf', 'connection', 'httpapi', 'inventory', 'lookup', 'shell', 'strategy', - 'vars', 'module', 'module_utils',)) + 'vars', 'module', 'module_utils', 'role',)) #: The subset of PLUGINS which we build documentation for DOCUMENTABLE_PLUGINS: FrozenSet[str] = frozenset(('become', 'cache', 'callback', 'cliconf', 'connection', 'httpapi', 'inventory', 'lookup', 'netconf', 'shell', 'vars', 'module', - 'strategy',)) + 'strategy', 'role',)) + + +DOCUMENTABLE_PLUGINS_MIN_VERSION: Dict[str, str] = { + 'role': '2.11.0', +} diff --git a/antsibull/data/collection-enum.py b/antsibull/data/collection-enum.py index 5de62b0d..fb96d299 100644 --- a/antsibull/data/collection-enum.py +++ b/antsibull/data/collection-enum.py @@ -128,6 +128,40 @@ def load_all_plugins(plugin_type, basedir, coll_filter): return result +def load_role(role_mixin, role_name, collection_name, collection_path): + result = { + 'directory': collection_path, + 'collection_name': collection_name, + } + + argspec = role_mixin._load_argspec(role_name, collection_path=collection_path) + fqcn, ansible_doc = role_mixin._build_doc( + role_name, collection_path, collection_name, argspec, None) + + try: + # If this fails, the documentation cannot be serialized as JSON + json.dumps(ansible_doc, cls=AnsibleJSONEncoder) + # Store result. This is guaranteed to be serializable + result['ansible-doc'] = ansible_doc + except Exception as e: + result['error'] = ( + 'Cannot serialize documentation as JSON: %s' % to_native(e) + ) + + return result + + +def load_all_roles(RoleMixin, basedir, coll_filter): + role_mixin = RoleMixin() + roles = role_mixin._find_all_collection_roles() + result = {} + for role_name, collection_name, collection_path in roles: + fqcn = '{1}.{0}'.format(role_name, collection_name) + if match_filter(fqcn, coll_filter): + result[fqcn] = load_role(role_mixin, role_name, collection_name, collection_path) + return result + + def load_collection_meta_manifest(b_manifest_path): with open(b_manifest_path, 'rb') as f: meta = json.load(f) @@ -195,6 +229,13 @@ def main(args): for plugin_type in C.DOCUMENTABLE_PLUGINS: result['plugins'][plugin_type] = load_all_plugins(plugin_type, basedir, coll_filter) + # Export role docs + RoleMixin = getattr(doc, 'RoleMixin', None) + if RoleMixin is not None: + result['plugins']['role'] = load_all_roles(RoleMixin, basedir, coll_filter) + else: + result['plugins']['role'] = {} + # Export collection data b_colldirs = list_collection_dirs(coll_filter=ansible_doc_coll_filter(coll_filter)) for b_path in b_colldirs: diff --git a/antsibull/data/docsite/list_of_plugins.rst.j2 b/antsibull/data/docsite/list_of_plugins.rst.j2 index fcaae0bf..dcefed98 100644 --- a/antsibull/data/docsite/list_of_plugins.rst.j2 +++ b/antsibull/data/docsite/list_of_plugins.rst.j2 @@ -5,6 +5,9 @@ {% if plugin_type == 'module' %} Index of all Modules ==================== +{% elif plugin_type == 'role' %} +Index of all Roles +================== {% else %} Index of all @{ plugin_type | capitalize }@ Plugins =============@{ '=' * (plugin_type | length) }@======== diff --git a/antsibull/data/docsite/plugins_by_collection.rst.j2 b/antsibull/data/docsite/plugins_by_collection.rst.j2 index e68329d7..be170876 100644 --- a/antsibull/data/docsite/plugins_by_collection.rst.j2 +++ b/antsibull/data/docsite/plugins_by_collection.rst.j2 @@ -32,17 +32,20 @@ Collection version @{ collection_version }@ Plugin Index ------------ -{% if plugin_maps %} +{% if plugin_maps | reject('eq', 'role') | list %} These are the plugins in the @{collection_name}@ collection {% else %} There are no plugins in the @{collection_name}@ collection with automatically generated documentation. {% endif %} -{% for category, plugins in plugin_maps.items() | sort %} +{% for category, plugins in plugin_maps.items() | sort | rejectattr('0', 'eq', 'role') %} {% if category == 'module' %} Modules ~~~~~~~ +{% elif category == 'role' %} +Roles +~~~~~ {% else %} @{ category | capitalize }@ Plugins @{ '~' * ((category | length) + 8) }@ @@ -53,6 +56,18 @@ Modules {% endfor %} {% endfor %} +{% if 'role' in plugin_maps %} +Role Index +---------- + +These are the roles in the @{collection_name}@ collection + +{% for name, desc in plugin_maps['role'].items() | sort %} +* :ref:`@{ name }@ ` -- @{ desc | rst_ify | indent(width=2) }@ +{% endfor %} + +{% endif %} + .. seealso:: diff --git a/antsibull/data/docsite/role.rst.j2 b/antsibull/data/docsite/role.rst.j2 new file mode 100644 index 00000000..c04d122b --- /dev/null +++ b/antsibull/data/docsite/role.rst.j2 @@ -0,0 +1,286 @@ +.. Document meta + +:orphan: + +{# If we can put together source and github repo, we could make the Edit me of github button work. + See meta.get("source") in Ansible's docs/docsite/_themes/sphinx_rtd_theme/breadcrumbs.html + for more information +:source: @{ source }@ +-#} + +.. Anchors + +.. _ansible_collections.@{plugin_name}@_@{plugin_type}@: + +.. Anchors: aliases + +{# TODO: This assumes that aliases will be short names. + If they're FQCN, then we need to change this +#} + +.. Title + +{% if entry_points.main and entry_points.main.short_description -%} +{% set title = plugin_name + ' -- ' + entry_points.main.short_description | rst_ify -%} +{% else -%} +{% set title = plugin_name -%} +{% endif -%} + +@{ title }@ +@{ '+' * title|length }@ + +.. Collection note + +{% if collection == 'ansible.builtin' -%} +.. note:: + This role is part of ``ansible-core`` and included in all Ansible + installations. +{% else %} +.. note:: + This role is part of the `@{collection}@ collection `_{% if collection_version %} (version @{ collection_version }@){% endif %}. + + To install it use: :code:`ansible-galaxy collection install @{collection}@`. + + To use it in a playbook, specify: :code:`@{plugin_name}@`. +{% endif %} + +.. contents:: + :local: + :depth: 2 + +{% for entry_point, ep_doc in entry_points.items() | sort %} + +.. Entry point title + +{% if ep_doc['short_description'] -%} +{% set title = 'Entry point ``' + entry_point + '`` -- ' + ep_doc['short_description'] | rst_ify -%} +{% else -%} +{% set title = 'Entry point ``' + entry_point + '``' -%} +{% endif -%} + +@{ title }@ +@{ '-' * title|length }@ + +.. version_added + +{% if ep_doc['version_added'] is still_relevant -%} +.. versionadded:: @{ ep_doc['version_added'] }@ of @{ collection | rst_ify }@ +{% endif %} + +.. Deprecated + +{% if ep_doc['deprecated'] -%} +DEPRECATED +^^^^^^^^^^ +{% if ep_doc['deprecated']['removed_at_date'] %} +:Removed in: major release after @{ ep_doc['deprecated']['removed_at_date'] | rst_ify }@ +{% elif ep_doc['deprecated']['removed_in'] %} +:Removed in: version @{ ep_doc['deprecated']['removed_in'] | rst_ify }@ +{% else %} +:Removed in: a future release +{% endif %} +:Why: @{ ep_doc['deprecated']['why'] | rst_ify }@ +:Alternative: @{ ep_doc['deprecated']['alternative'] | rst_ify }@ +{% endif %} + +Synopsis +^^^^^^^^ + +.. Description + +{% for desc in ep_doc['description'] -%} +- @{ desc | rst_ify | indent(width=2) }@ +{% endfor %} + +.. Requirements + +{% if ep_doc['requirements'] -%} +Requirements +^^^^^^^^^^^^ +The below requirements are needed on the remote host and/or the local controller node. + +{% for req in ep_doc['requirements'] %} +- @{ req | rst_ify | indent(width=2) }@ +{% endfor %} + +{% endif %} + +.. Options + +{% if ep_doc['options'] -%} + +Parameters +^^^^^^^^^^ + +.. raw:: html + + + {# Pre-compute the nesting depth to allocate columns -#} + @{ to_kludge_ns('maxdepth', 1) -}@ + {% for key, value in ep_doc['options']|dictsort recursive -%} + @{ to_kludge_ns('maxdepth', [loop.depth, from_kludge_ns('maxdepth')] | max) -}@ + {% if value['options'] -%} + @{ loop(value['options'].items()) -}@ + {% endif -%} + {% endfor -%} + {# Header of the documentation -#} + + + + + + {% for key, value in ep_doc['options']|dictsort recursive %} + + {# indentation based on nesting level #} + {% for i in range(1, loop.depth) %} + + {% endfor %} + {# parameter name with required and/or introduced label #} + + {# default / choices #} + + {# description #} + + + {% if value['options'] %} + @{ loop(value['options']|dictsort) }@ + {% endif %} + {% endfor %} +
ParameterChoices/DefaultsComments
+
+ @{ key }@ + +
+ @{ value['type'] | documented_type }@ + {% if value['type'] == 'list' and value['elements'] is not none %} / elements=@{ value['elements'] | documented_type }@{% endif %} + {% if value['required'] %} / required{% endif %} +
+ {% if value['version_added'] is still_relevant %} +
+ added in @{value['version_added']}@ of @{ value['version_added_collection'] | escape }@ +
+ {% endif %} + {% if value['deprecated'] %} +
+ {% if value['deprecated']['removed_at_date'] %} + Removed in: major release after @{ value['deprecated']['removed_at_date'] | escape }@ + {% elif value['deprecated']['removed_in'] %} + Removed in: version @{ value['deprecated']['removed_in'] | escape }@ + {% else %} + Removed in: a future release + {% endif %} + {% if value['deprecated']['removed_from_collection'] and value['deprecated']['removed_from_collection'] != collection %} + of @{ value['deprecated']['removed_from_collection'] | escape }@ + {% endif %} +
+ Why: @{ value['deprecated']['why'] | escape }@ +
+ Alternative: @{ value['deprecated']['alternative'] | escape }@ +
+ {% endif %} +
+ {# Turn boolean values in 'yes' and 'no' values #} + {% if value['default'] is sameas true %} + {% set _x = value.update({'default': 'yes'}) %} + {% elif value['default'] is not none and value['default'] is sameas false %} + {% set _x = value.update({'default': 'no'}) %} + {% endif %} + {% if value['type'] == 'bool' %} + {% set _x = value.update({'choices': ['no', 'yes']}) %} + {% endif %} + {# Show possible choices and highlight details #} + {% if value['choices'] %} +
    Choices: + {% for choice in value['choices'] %} + {# Turn boolean values in 'yes' and 'no' values #} + {% if choice is sameas true %} + {% set choice = 'yes' %} + {% elif choice is sameas false %} + {% set choice = 'no' %} + {% endif %} + {% if (value['default'] is not list and value['default'] == choice) or (value['default'] is list and choice in value['default']) %} +
  • @{ choice | escape }@ ←
  • + {% else %} +
  • @{ choice | escape }@
  • + {% endif %} + {% endfor %} +
+ {% endif %} + {# Show default value, when multiple choice or no choices #} + {% if value['default'] is not none and value['default'] not in value['choices'] %} + Default:
@{ value['default'] | tojson | escape }@
+ {% endif %} +
+ {% for desc in value['description'] %} +
@{ desc | replace('\n', '\n ') | html_ify }@
+ {% endfor %} + {% if value['aliases'] %} +

aliases: @{ value['aliases']|join(', ') }@
+ {% endif %} +
+
+{% endif %} + +.. Notes + +{% if ep_doc['notes'] -%} +Notes +^^^^^ + +.. note:: +{% for note in ep_doc['notes'] %} + - @{ note | rst_ify | indent(width=5) }@ +{% endfor %} +{% endif %} + +.. Seealso + +{% if ep_doc['seealso'] -%} +See Also +^^^^^^^^ + +.. seealso:: + +{% for item in ep_doc['seealso'] %} +{% if item.module is defined and item.description %} + @{ ('M(' + item['module'] + ')') | rst_ify }@ + @{ item['description'] | rst_ify }@ +{% elif item.module is defined %} + @{ ('M(' + item['module'] + ')') | rst_ify }@ + The official documentation on the **@{ item['module'] }@** module. +{% elif item.name is defined and item.link is defined and item.description %} + `@{ item['name'] }@ <@{ item['link'] }@>`_ + @{ item['description'] | rst_ify }@ +{% elif item.ref is defined and item.description %} + :ref:`@{ item['ref'] }@` + @{ item['description'] | rst_ify }@ +{% endif %} +{% endfor %} +{% endif %} + +{% if ep_doc['author'] -%} +Authors +^^^^^^^ + +{% for author_name in ep_doc['author'] %} +- @{ author_name }@ +{% endfor %} + +{% endif %} + +{% endfor %} + +.. Parsing errors + +{% if nonfatal_errors %} +There were some errors parsing the documentation for this role. Please file a bug with the collection. + +The errors were: + +{% for error in nonfatal_errors %} +* :: + +@{ error | indent(width=8, first=True) }@ + +{% endfor %} +{% endif %} diff --git a/antsibull/docs_parsing/ansible_doc.py b/antsibull/docs_parsing/ansible_doc.py index 0a774bd1..a8000003 100644 --- a/antsibull/docs_parsing/ansible_doc.py +++ b/antsibull/docs_parsing/ansible_doc.py @@ -12,10 +12,11 @@ from concurrent.futures import ThreadPoolExecutor import sh +from packaging.version import Version as PypiVer from .. import app_context from ..compat import best_get_loop, create_task -from ..constants import DOCUMENTABLE_PLUGINS +from ..constants import DOCUMENTABLE_PLUGINS, DOCUMENTABLE_PLUGINS_MIN_VERSION from ..logging import log from ..vendored.json_utils import _filter_non_json_lines from .fqcn import get_fqcn_parts @@ -203,6 +204,16 @@ def get_collection_metadata(venv: t.Union['VenvRunner', 'FakeVenvRunner'], return collection_metadata +def get_ansible_core_version(venv: t.Union['VenvRunner', 'FakeVenvRunner'], + env: t.Dict[str, str], + ) -> PypiVer: + venv_python = venv.get_command('python') + ansible_version_cmd = venv_python( + '-c', 'import ansible.release; print(ansible.release.__version__)', _env=env) + output = ansible_version_cmd.stdout.decode('utf-8', errors='surrogateescape').strip() + return PypiVer(output) + + async def get_ansible_plugin_info(venv: t.Union['VenvRunner', 'FakeVenvRunner'], collection_dir: t.Optional[str], collection_names: t.Optional[t.List[str]] = None @@ -232,6 +243,8 @@ async def get_ansible_plugin_info(venv: t.Union['VenvRunner', 'FakeVenvRunner'], env = _get_environment(collection_dir) + ansible_core_version = get_ansible_core_version(venv, env) + # Setup an sh.Command to run ansible-doc from the venv with only the collections we # found as providers of extra plugins. @@ -253,8 +266,13 @@ async def get_ansible_plugin_info(venv: t.Union['VenvRunner', 'FakeVenvRunner'], if other_workers < 1: other_workers = 1 + plugin_map = {} extractors = {} for plugin_type in DOCUMENTABLE_PLUGINS: + min_ver = DOCUMENTABLE_PLUGINS_MIN_VERSION.get(plugin_type) + if min_ver is not None and PypiVer(min_ver) > ansible_core_version: + plugin_map[plugin_type] = {} + continue if plugin_type == 'module': max_workers = module_workers else: @@ -264,7 +282,6 @@ async def get_ansible_plugin_info(venv: t.Union['VenvRunner', 'FakeVenvRunner'], results = await asyncio.gather(*extractors.values(), return_exceptions=True) - plugin_map = {} err_msg = [] an_exception = None for plugin_type, extraction_result in zip(extractors, results): diff --git a/antsibull/schemas/docs/__init__.py b/antsibull/schemas/docs/__init__.py index c0fb70cc..d3819796 100644 --- a/antsibull/schemas/docs/__init__.py +++ b/antsibull/schemas/docs/__init__.py @@ -13,6 +13,7 @@ from .module import ModuleDocSchema, ModuleSchema from .plugin import (PluginDocSchema, PluginExamplesSchema, PluginMetadataSchema, PluginReturnSchema, PluginSchema) +from .role import RoleSchema BecomeSchema = PluginSchema CacheSchema = PluginSchema @@ -67,4 +68,5 @@ 'shell': _PLUGIN_SCHEMA_RECORD, 'strategy': _PLUGIN_SCHEMA_RECORD, 'vars': _PLUGIN_SCHEMA_RECORD, + 'role': RoleSchema, } diff --git a/antsibull/schemas/docs/ansible_doc.py b/antsibull/schemas/docs/ansible_doc.py index 354e4738..786bbdee 100644 --- a/antsibull/schemas/docs/ansible_doc.py +++ b/antsibull/schemas/docs/ansible_doc.py @@ -19,6 +19,7 @@ from .callback import CallbackSchema from .module import ModuleSchema from .plugin import PluginSchema +from .role import RoleSchema __all__ = ('ANSIBLE_DOC_SCHEMAS', 'AnsibleDocSchema', 'BecomePluginSchema', 'CachePluginSchema', @@ -61,6 +62,7 @@ class AnsibleDocSchema(BaseModel): shell: t.Dict[str, PluginSchema] strategy: t.Dict[str, PluginSchema] vars: t.Dict[str, PluginSchema] + role: t.Dict[str, RoleSchema] class GenericPluginSchema(BaseModel): @@ -99,6 +101,18 @@ class ModulePluginSchema(BaseModel): __root__: t.Dict[str, ModuleSchema] +class RolePluginSchema(BaseModel): + """ + Document the output of ``ansible-doc -t role ROLE_NAME``. + + .. note:: Both the model and the dict will be wrapped in an outer dict with your data mapped + to the ``__root__`` key. This happens because the toplevel key of ansible-doc's output is + a dynamic key which we can't automatically map to an attribute name. + """ + + __root__: t.Dict[str, RoleSchema] + + #: Make sure users can access plugins using the plugin type rather than having to guess that #: these types use the GenericPluginSchema BecomePluginSchema = GenericPluginSchema @@ -127,6 +141,7 @@ class ModulePluginSchema(BaseModel): 'lookup': LookupPluginSchema, 'module': ModulePluginSchema, 'netconf': NetConfPluginSchema, + 'role': RolePluginSchema, 'shell': ShellPluginSchema, 'strategy': StrategyPluginSchema, 'vars': VarsPluginSchema, diff --git a/antsibull/schemas/docs/role.py b/antsibull/schemas/docs/role.py new file mode 100644 index 00000000..d993daff --- /dev/null +++ b/antsibull/schemas/docs/role.py @@ -0,0 +1,60 @@ +# coding: utf-8 +# Author: Toshio Kuratomi +# Author: Felix Fontein +# License: GPLv3+ +# Copyright: Ansible Project, 2021 +"""Schemas for the role documentation data.""" + +# Ignore Unitialized attribute errors because BaseModel works some magic +# to initialize the attributes when data is loaded into them. +# pyre-ignore-all-errors[13] + +import typing as t + +import pydantic as p + +from .base import ( + BaseModel, DeprecationSchema, OptionsSchema, + SeeAlsoModSchema, SeeAlsoRefSchema, SeeAlsoLinkSchema, + COLLECTION_NAME_F, +) + + +class InnerRoleOptionsSchema(OptionsSchema): + options: t.Dict[str, 'InnerRoleOptionsSchema'] = {} + + @p.root_validator(pre=True) + def allow_description_to_be_optional(cls, values): + # Doing this in a validator so that the json-schema will still flag it as an error + if 'description' not in values: + values['description'] = [] + return values + + +InnerRoleOptionsSchema.update_forward_refs() + + +class RoleOptionsSchema(OptionsSchema): + options: t.Dict[str, 'InnerRoleOptionsSchema'] = {} + + +class RoleEntrypointSchema(BaseModel): + """Documentation for role entrypoints.""" + description: t.List[str] + short_description: str + author: t.List[str] = [] + deprecated: DeprecationSchema = p.Field({}) + notes: t.List[str] = [] + requirements: t.List[str] = [] + seealso: t.List[t.Union[SeeAlsoModSchema, SeeAlsoRefSchema, SeeAlsoLinkSchema]] = [] + todo: t.List[str] = [] + version_added: str = 'historical' + + options: t.Dict[str, RoleOptionsSchema] = {} + + +class RoleSchema(BaseModel): + """Documentation for roles.""" + collection: str = COLLECTION_NAME_F + entry_points: t.Dict[str, RoleEntrypointSchema] + path: str diff --git a/antsibull/write_docs.py b/antsibull/write_docs.py index 5caf9e61..38193b4a 100644 --- a/antsibull/write_docs.py +++ b/antsibull/write_docs.py @@ -107,15 +107,24 @@ async def write_plugin_rst(collection_name: str, collection_meta: AnsibleCollect nonfatal_errors=nonfatal_errors ).error('{plugin_name} did not return correct RETURN or EXAMPLES.', plugin_name=plugin_name) - plugin_contents = plugin_tmpl.render( - collection=collection_name, - collection_version=collection_meta.version, - plugin_type=plugin_type, - plugin_name=plugin_name, - doc=plugin_record['doc'], - examples=plugin_record['examples'], - returndocs=plugin_record['return'], - nonfatal_errors=nonfatal_errors) + if plugin_type == 'role': + plugin_contents = plugin_tmpl.render( + collection=collection_name, + collection_version=collection_meta.version, + plugin_type=plugin_type, + plugin_name=plugin_name, + entry_points=plugin_record['entry_points'], + nonfatal_errors=nonfatal_errors) + else: + plugin_contents = plugin_tmpl.render( + collection=collection_name, + collection_version=collection_meta.version, + plugin_type=plugin_type, + plugin_name=plugin_name, + doc=plugin_record['doc'], + examples=plugin_record['examples'], + returndocs=plugin_record['return'], + nonfatal_errors=nonfatal_errors) if path_override is not None: plugin_file = path_override @@ -228,6 +237,7 @@ async def output_all_plugin_rst(collection_to_plugin_info: CollectionInfoT, env = doc_environment(('antsibull.data', 'docsite')) # Get the templates plugin_tmpl = env.get_template('plugin.rst.j2') + role_tmpl = env.get_template('role.rst.j2') error_tmpl = env.get_template('plugin-error.rst.j2') writers = [] @@ -235,6 +245,9 @@ async def output_all_plugin_rst(collection_to_plugin_info: CollectionInfoT, async with asyncio_pool.AioPool(size=lib_ctx.thread_max) as pool: for collection_name, plugins_by_type in collection_to_plugin_info.items(): for plugin_type, plugins in plugins_by_type.items(): + plugin_type_tmpl = plugin_tmpl + if plugin_type == 'role': + plugin_type_tmpl = role_tmpl for plugin_short_name, dummy_ in plugins.items(): plugin_name = '.'.join((collection_name, plugin_short_name)) writers.append(await pool.spawn( @@ -242,8 +255,9 @@ async def output_all_plugin_rst(collection_to_plugin_info: CollectionInfoT, collection_metadata[collection_name], plugin_short_name, plugin_type, plugin_info[plugin_type].get(plugin_name), - nonfatal_errors[plugin_type][plugin_name], plugin_tmpl, - error_tmpl, dest_dir, squash_hierarchy=squash_hierarchy))) + nonfatal_errors[plugin_type][plugin_name], + plugin_type_tmpl, error_tmpl, + dest_dir, squash_hierarchy=squash_hierarchy))) # Write docs for each plugin await asyncio.gather(*writers) diff --git a/tests/functional/schema/good_data/one_role.json b/tests/functional/schema/good_data/one_role.json new file mode 100644 index 00000000..ac369f91 --- /dev/null +++ b/tests/functional/schema/good_data/one_role.json @@ -0,0 +1,85 @@ +{ + "felixfontein.acme.account_key_rollover": { + "collection": "felixfontein.acme", + "entry_points": { + "main": { + "author": [ + "Felix Fontein (@felixfontein)" + ], + "description": [ + "This is a role which can use any CA supporting the ACME protocol, such as L(Let's Encrypt,https://letsencrypt.org/), L(Buypass,https://www.buypass.com/ssl/products/acme>) or L(ZeroSSL,https://zerossl.com/features/acme/>), to rekey ACME account keys.", + "This role will create a backup copy of the existing account key if requested to do so, re-create the account key, and then roll over the ACME account to the new key." + ], + "options": { + "acme_certificate_account_algorithm": { + "choices": [ + "rsa", + "p-256", + "p-384", + "p-521" + ], + "default": "rsa", + "description": [ + "The algorithm used for creating the account key.", + "The default is C(rsa) for an RSA key.", + "Other choices are C(p-256), C(p-384) or C(p-521) for the NIST elliptic curves C(prime256v1), C(secp384r1) and C(secp521r1), respectively." + ], + "type": "str" + }, + "acme_certificate_account_key_backup": { + "default": true, + "description": [ + "Whether to create a backup of the old account key before rolling over." + ], + "type": "bool" + }, + "acme_certificate_account_key_length": { + "default": 4096, + "description": [ + "The bit-size to use for RSA private keys.", + "Should not be less than 2048. Also values above 4096 might not be supported by every ACME CA." + ], + "type": "int" + }, + "acme_certificate_account_key_sops_encrypted": { + "default": false, + "description": [ + "Use L(Mozilla sops,https://github.com/mozilla/sops) to encrypt private key. Needs C(.sops.yaml) file inside the directory containing the account key or somewhere up the directory chain." + ], + "type": "bool" + }, + "acme_certificate_acme_account": { + "description": [ + "Path to the private ACME account key." + ], + "type": "str" + }, + "acme_certificate_acme_account_uri": { + "description": [ + "Instead of determining the account URI from the account key, assumes the given account URI." + ], + "type": "str" + }, + "acme_certificate_acme_directory": { + "default": "https://acme-v02.api.letsencrypt.org/directory", + "description": [ + "The ACME directory to use.", + "Default is C(https://acme-v02.api.letsencrypt.org/directory), which is the current production ACME v2 endpoint of Let's Encrypt." + ], + "type": "str" + }, + "acme_certificate_acme_version": { + "default": 2, + "description": [ + "The ACME directory's version." + ], + "type": "int" + } + }, + "short_description": "Do account key rollover", + "version_added": "0.1.0" + } + }, + "path": "/path/to/ansible_collections/felixfontein/acme" + } +} diff --git a/tests/functional/schema/good_data/one_role_results.json b/tests/functional/schema/good_data/one_role_results.json new file mode 100644 index 00000000..ac369f91 --- /dev/null +++ b/tests/functional/schema/good_data/one_role_results.json @@ -0,0 +1,85 @@ +{ + "felixfontein.acme.account_key_rollover": { + "collection": "felixfontein.acme", + "entry_points": { + "main": { + "author": [ + "Felix Fontein (@felixfontein)" + ], + "description": [ + "This is a role which can use any CA supporting the ACME protocol, such as L(Let's Encrypt,https://letsencrypt.org/), L(Buypass,https://www.buypass.com/ssl/products/acme>) or L(ZeroSSL,https://zerossl.com/features/acme/>), to rekey ACME account keys.", + "This role will create a backup copy of the existing account key if requested to do so, re-create the account key, and then roll over the ACME account to the new key." + ], + "options": { + "acme_certificate_account_algorithm": { + "choices": [ + "rsa", + "p-256", + "p-384", + "p-521" + ], + "default": "rsa", + "description": [ + "The algorithm used for creating the account key.", + "The default is C(rsa) for an RSA key.", + "Other choices are C(p-256), C(p-384) or C(p-521) for the NIST elliptic curves C(prime256v1), C(secp384r1) and C(secp521r1), respectively." + ], + "type": "str" + }, + "acme_certificate_account_key_backup": { + "default": true, + "description": [ + "Whether to create a backup of the old account key before rolling over." + ], + "type": "bool" + }, + "acme_certificate_account_key_length": { + "default": 4096, + "description": [ + "The bit-size to use for RSA private keys.", + "Should not be less than 2048. Also values above 4096 might not be supported by every ACME CA." + ], + "type": "int" + }, + "acme_certificate_account_key_sops_encrypted": { + "default": false, + "description": [ + "Use L(Mozilla sops,https://github.com/mozilla/sops) to encrypt private key. Needs C(.sops.yaml) file inside the directory containing the account key or somewhere up the directory chain." + ], + "type": "bool" + }, + "acme_certificate_acme_account": { + "description": [ + "Path to the private ACME account key." + ], + "type": "str" + }, + "acme_certificate_acme_account_uri": { + "description": [ + "Instead of determining the account URI from the account key, assumes the given account URI." + ], + "type": "str" + }, + "acme_certificate_acme_directory": { + "default": "https://acme-v02.api.letsencrypt.org/directory", + "description": [ + "The ACME directory to use.", + "Default is C(https://acme-v02.api.letsencrypt.org/directory), which is the current production ACME v2 endpoint of Let's Encrypt." + ], + "type": "str" + }, + "acme_certificate_acme_version": { + "default": 2, + "description": [ + "The ACME directory's version." + ], + "type": "int" + } + }, + "short_description": "Do account key rollover", + "version_added": "0.1.0" + } + }, + "path": "/path/to/ansible_collections/felixfontein/acme" + } +}